trusty-console 0.9.2

Web console that detects and surfaces running trusty services as a home page with service cards
Documentation
//! build.rs — build the UIs before compiling so rust-embed has assets.
//!
//! Why: The web console ships TWO embedded Svelte SPAs. Its own management UI
//! lives in `ui/src/` and builds to `ui/dist/`; the search dashboard lives in
//! `ui-search/src/` and builds to `ui-search-dist/`, which `src/tools_ui.rs`
//! serves at `/tools/search/`. Running `pnpm build` here means a plain
//! `cargo build` always produces a binary with up-to-date assets, with no
//! separate UI build step.
//!
//! #6155: the search dashboard's source used to live in `crates/trusty-search`
//! and reach this crate as a copied artefact, which is why build.rs built only
//! one of the two bundles. The source now lives here, so both are built the
//! same way from this crate's own tree.
//! What: Skips entirely if `SKIP_UI_BUILD=1` (CI / first-time bootstrap
//! when pnpm is unavailable). Otherwise runs `<pm> install [--frozen-lockfile]`
//! followed by `<pm> run build` in each UI directory. Emits cargo:rerun
//! directives so a `cargo build` only re-runs the JS pipeline when UI sources
//! change.
//!
//! NOTE: The core UI-build logic (SKIP_UI_BUILD guard, pnpm detection,
//! install+build pipeline, placeholder fallback, and the #5078 committed-bundle
//! freshness guard) is intentionally kept identical across trusty-memory,
//! trusty-analyze, trusty-console, and trusty-search (issue #987).
//! `scripts/check_buildrs_sync.sh` asserts that the canonical implementation
//! block does not drift between these four files.
//!
//! Test: `SKIP_UI_BUILD=1 cargo check -p trusty-console` exits without invoking
//! pnpm; a normal `cargo build` populates `ui/dist/index.html` and
//! `ui-search-dist/index.html`.

use std::path::{Path, PathBuf};
use std::process::Command;

fn main() {
    let crate_root = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default());

    println!("cargo:rerun-if-env-changed=SKIP_UI_BUILD");
    println!("cargo:rerun-if-env-changed=FORCE_UI_BUILD");
    println!("cargo:rerun-if-changed=ui/package.json");
    println!("cargo:rerun-if-changed=ui/vite.config.js");
    println!("cargo:rerun-if-changed=ui/index.html");
    println!("cargo:rerun-if-changed=ui/src");
    // #6155: the search dashboard is this crate's second UI project.
    println!("cargo:rerun-if-changed=ui-search/package.json");
    println!("cargo:rerun-if-changed=ui-search/vite.config.js");
    println!("cargo:rerun-if-changed=ui-search/index.html");
    println!("cargo:rerun-if-changed=ui-search/src");

    // The console's own management UI, embedded from `ui/dist/`.
    build_bundle(
        &crate_root,
        &crate_root.join("ui"),
        &crate_root.join("ui").join("dist"),
        "trusty-console",
    );

    // #6155: the search dashboard, embedded from `ui-search-dist/`. Its Vite
    // config writes straight to that crate-root directory, so there is no
    // mirror step and nothing is left behind under `ui-search/`.
    build_bundle(
        &crate_root,
        &crate_root.join("ui-search"),
        &crate_root.join("ui-search-dist"),
        "trusty-console-search",
    );
}

/// Rebuild one committed UI bundle, unless it already matches its source.
///
/// Why: #5078 — `cargo test -p trusty-console` used to run the UI install and
/// build unconditionally, and both write files git tracks: `vite build` empties
/// the bundle directory, taking `ui-source-hash.txt` with it. The committed
/// bundle already matches the committed source in every checkout that is not
/// mid-UI-edit, so rebuilding it is work that only dirties the tree.
/// What: consults the freshness gate, returns early when the bundle is current,
/// and otherwise runs the canonical build pipeline and re-stamps only when the
/// build actually ran. `stamp_key` is the bundle's `ui-bundle-manifest.tsv` row
/// key — `trusty-console` for `ui/dist/`, `trusty-console-search` for
/// `ui-search-dist/`. The two keys must differ: `stamp-ui-bundle.sh` stamps
/// every row matching the name it is given, so a shared key would have one
/// bundle's build certify the other.
/// Test: `git status --porcelain` is empty after `cargo test -p trusty-console`;
/// `FORCE_UI_BUILD=1 cargo build -p trusty-console` rebuilds and re-stamps both.
fn build_bundle(crate_root: &Path, ui_dir: &Path, dist_dir: &Path, stamp_key: &str) {
    if std::env::var("FORCE_UI_BUILD").as_deref() != Ok("1")
        && committed_bundle_is_fresh(crate_root, dist_dir, stamp_key)
    {
        return;
    }
    if build_svelte_ui(ui_dir, dist_dir, stamp_key) {
        restamp_bundle(crate_root, stamp_key);
    }
}

// ── CANONICAL BLOCK BEGIN (kept in sync by scripts/check_buildrs_sync.sh) ──

/// Whether the committed bundle directory was built from the UI source now on
/// disk, so rebuilding it would produce the same thing.
///
/// Why: see the `#5078` note in each crate's `main`. The freshness question
/// already has one answer in this repo — `scripts/check-ui-bundle-freshness.sh`,
/// which preflight-publish.sh runs as CHECK 7 — so this asks that script rather
/// than minting a second definition of "fresh" that could disagree with it.
/// What: `true` when the script reports the bundle fresh, when it is absent
/// (a published tarball ships the bundle and has no `scripts/`), or when it
/// cannot answer; `false` only on exit 1, the script's "stale bundle" finding.
/// Skipping on an unreadable answer keeps the tree clean, which is the
/// invariant #5078 asks for; the publish-time gate still refuses a stale
/// bundle either way.
/// Test: `git status --porcelain` is empty after `cargo test -p <crate>`.
fn committed_bundle_is_fresh(crate_root: &Path, dist_dir: &Path, crate_name: &str) -> bool {
    if !dist_dir.join("index.html").exists() {
        return false;
    }
    let Some(repo_root) = crate_root.parent().and_then(Path::parent) else {
        return true;
    };
    let script = repo_root.join("scripts/check-ui-bundle-freshness.sh");
    if !script.exists() {
        return true;
    }
    match Command::new("bash")
        .arg(&script)
        .arg(crate_name)
        .current_dir(repo_root)
        .output()
    {
        Ok(out) if out.status.success() => true,
        Ok(out) if out.status.code() == Some(1) => false,
        _ => {
            println!(
                "cargo:warning={crate_name}: could not check UI bundle freshness — \
                 keeping the committed bundle (set FORCE_UI_BUILD=1 to rebuild)."
            );
            true
        }
    }
}

/// Record which UI source the bundle just built came from.
///
/// Why: `vite build` empties the dist directory, so a build that ran leaves the
/// freshness stamp deleted — a tracked-file deletion that lands in whatever
/// commit follows. Re-stamping leaves a coherent bundle instead: unchanged
/// source rewrites the same bytes, changed source writes the digest the
/// publish gate will look for.
/// What: runs `scripts/stamp-ui-bundle.sh <crate_name>`, warning if it fails.
///
/// Call this ONLY when a build actually produced the bundle. The stamp is a
/// claim that `check-ui-bundle-freshness.sh` trusts and cannot re-verify — its
/// own header says re-stamping without rebuilding falsifies it undetectably —
/// so writing it after a skipped or failed build turns a BUNDLE-STALE finding
/// green while the stale bundle is still the one on disk. That is why
/// `build_svelte_ui` reports whether it ran, rather than the caller assuming
/// it did.
/// Test: `git status --porcelain` is empty after `FORCE_UI_BUILD=1 cargo build
/// -p <crate>` with no UI source edit, and after `SKIP_UI_BUILD=1 cargo check
/// -p <crate>` against a stale committed bundle.
fn restamp_bundle(crate_root: &Path, crate_name: &str) {
    let Some(repo_root) = crate_root.parent().and_then(Path::parent) else {
        return;
    };
    let script = repo_root.join("scripts/stamp-ui-bundle.sh");
    if !script.exists() {
        return;
    }
    let ok = Command::new("bash")
        .arg(&script)
        .arg(crate_name)
        .current_dir(repo_root)
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    if !ok {
        println!(
            "cargo:warning={crate_name}: the bundle was rebuilt but stamp-ui-bundle.sh \
             failed — run it by hand before committing the bundle."
        );
    }
}

/// Run the Svelte UI build pipeline, or degrade gracefully to a placeholder.
///
/// Why: Centralises SKIP_UI_BUILD handling, pnpm detection, frozen-lockfile
/// install, and placeholder fallback so all four UI-embedding crates share
/// identical logic without a published build-helper crate (#987).
/// What: Checks SKIP_UI_BUILD, detects pnpm/npm, runs install + build inside
/// `ui_dir`, writes a placeholder on any failure so the Rust build still
/// completes even without the JS toolchain. Returns `true` only when the
/// package manager's `build` script actually ran and succeeded, so the caller
/// knows whether `dist_dir` holds output this invocation produced.
/// Test: `SKIP_UI_BUILD=1 cargo check` short-circuits and returns `false`;
/// `cargo build` with pnpm installed populates `dist_dir/index.html` with real
/// Vite output and returns `true`.
fn build_svelte_ui(ui_dir: &Path, dist_dir: &Path, crate_name: &str) -> bool {
    // Step 1: honour explicit skip (CI / `cargo publish --verify`).
    if std::env::var("SKIP_UI_BUILD").as_deref() == Ok("1") {
        if !dist_dir.join("index.html").exists() {
            println!(
                "cargo:warning=SKIP_UI_BUILD=1 but {dist}/ is empty — \
                 run `pnpm --dir ui install && pnpm --dir ui build` before publishing.",
                dist = dist_dir.display()
            );
            ensure_placeholder(dist_dir, crate_name);
        }
        return false;
    }

    // Step 2: no `ui/package.json` means we are inside an extracted tarball
    // that already shipped the dist — nothing to build.
    if !ui_dir.join("package.json").exists() {
        ensure_placeholder(dist_dir, crate_name);
        return false;
    }

    // Step 3: detect package manager (pnpm preferred, npm fallback).
    let Some(pm) = detect_pm() else {
        println!(
            "cargo:warning={crate_name}: no pnpm/npm on PATH — skipping UI \
             build (set SKIP_UI_BUILD=1 to silence, or install pnpm)."
        );
        ensure_placeholder(dist_dir, crate_name);
        return false;
    };

    // Step 4a: install — prefer frozen lockfile when pnpm-lock.yaml exists.
    // Only pass --frozen-lockfile when pnpm is the detected manager; npm does
    // not support that flag (it uses --ci instead) and will error out.
    let mut install_args = vec!["install"];
    if pm == "pnpm" && ui_dir.join("pnpm-lock.yaml").exists() {
        install_args.push("--frozen-lockfile");
    }
    let install_ok = Command::new(pm)
        .args(&install_args)
        .current_dir(ui_dir)
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    if !install_ok {
        println!("cargo:warning={crate_name}: `{pm} install` failed — embedding placeholder UI.");
        ensure_placeholder(dist_dir, crate_name);
        return false;
    }

    // Step 4b: build.
    let build_ok = Command::new(pm)
        .args(["run", "build"])
        .current_dir(ui_dir)
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    if !build_ok {
        println!("cargo:warning={crate_name}: `{pm} run build` failed — embedding placeholder UI.");
        ensure_placeholder(dist_dir, crate_name);
    }
    build_ok
}

/// Write a stub `index.html` so embed macros compile without the JS build.
///
/// Why: `rust_embed` and `include_dir!` fail at compile time if the referenced
/// directory is absent or empty; a single-file stub is the minimum viable
/// artefact that satisfies both macros while making the "UI not built" state
/// obvious to anyone who opens `/` in a browser.
/// What: Creates `dist_dir` if needed and writes a minimal HTML document.
/// Idempotent — exits immediately if `index.html` already exists.
/// Test: After `SKIP_UI_BUILD=1 cargo build`, `dist_dir/index.html` exists.
fn ensure_placeholder(dist_dir: &Path, crate_name: &str) {
    if dist_dir.join("index.html").exists() {
        return;
    }
    let _ = std::fs::create_dir_all(dist_dir);
    let html = format!(
        "<!doctype html><html><body><p>{crate_name}: UI assets not built. \
         Run <code>pnpm --dir ui install &amp;&amp; pnpm --dir ui build</code> \
         and rebuild.</p></body></html>"
    );
    let _ = std::fs::write(dist_dir.join("index.html"), html);
}

/// Detect the available Node.js package manager on PATH.
///
/// Why: The workspace uses pnpm (lockfile is `pnpm-lock.yaml`), but `npm`
/// works as a fallback on machines that have Node but not pnpm separately.
/// What: Probes `pnpm --version` then `npm --version`; returns the first
/// that exits 0, or `None` when neither is found.
/// Test: `detect_pm()` returns `Some("pnpm")` on a standard dev machine;
/// `Some("npm")` on a machine without pnpm; `None` in a bare container.
fn detect_pm() -> Option<&'static str> {
    let ok = |bin: &str| {
        Command::new(bin)
            .arg("--version")
            .output()
            .map(|o| o.status.success())
            .unwrap_or(false)
    };
    if ok("pnpm") {
        Some("pnpm")
    } else if ok("npm") {
        Some("npm")
    } else {
        None
    }
}

// ── CANONICAL BLOCK END ──