holger-ui 0.1.4

Operator/admin UI for holger over the HolgerObject core API — egui via facett, embedded (LocalHolger, direct core calls) or remote (RemoteHolger gRPC).
//! `holger-ui` — the holger operator/admin desktop UI (egui via facett).
//!
//! Usage:
//! ```text
//! holger-ui [ENDPOINT] [--token <TOKEN>] [--ca <CA.pem>] [--cert <CERT.pem> --key <KEY.pem>]
//! ```
//! `ENDPOINT` is the holger gRPC address (default `http://127.0.0.1:50051`). A
//! bearer token may be supplied with `--token <TOKEN>` or the `HOLGER_TOKEN`
//! environment variable (needed for write access on an auth-enabled server).
//!
//! TLS / mTLS: pass `--ca <CA.pem>` to verify the server against a custom CA,
//! and `--cert <CERT.pem> --key <KEY.pem>` to present a client certificate
//! (mTLS — the CN → role auth path). When any of these are present the UI
//! connects over TLS; otherwise it uses the plain (token-only) path. Use an
//! `https://` endpoint with TLS.
//!
//! The UI connects with `RemoteHolger`; because the view-model is generic over
//! `HolgerObject`, an embedded build could hand it a `LocalHolger` instead for
//! direct in-process core calls (no network).

use holger_ui::app::HolgerUiApp;
use holger_ui::data::UiData;

fn main() -> eframe::Result<()> {
    // Tiny hand-rolled arg parse: first non-flag positional is the endpoint;
    // `--token <t>` (or $HOLGER_TOKEN) is the bearer token; `--ca/--cert/--key`
    // are PEM paths for the TLS / mTLS path.
    let mut endpoint = "http://127.0.0.1:50051".to_string();
    let mut token = std::env::var("HOLGER_TOKEN").ok();
    let mut ca_path: Option<String> = None;
    let mut cert_path: Option<String> = None;
    let mut key_path: Option<String> = None;
    // 🎬 Demo mode: fill an in-process dev store with real crates (offline-safe
    // fixture fallback) and ROBOT-DRIVE the UI through every view. `--demo-offline`
    // forces the bundled fixture (no network).
    let mut demo = false;
    let mut demo_offline = false;
    let mut args = std::env::args().skip(1);
    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--token" => token = args.next(),
            "--ca" => ca_path = args.next(),
            "--cert" => cert_path = args.next(),
            "--key" => key_path = args.next(),
            "--demo" => demo = true,
            "--demo-offline" => {
                demo = true;
                demo_offline = true;
            }
            other => endpoint = other.to_string(),
        }
    }

    // ── 🎬 Demo mode: self-contained, no server needed ───────────────────────
    if demo {
        return run_demo(!demo_offline);
    }

    // Read any PEM material up front so a missing file fails before the GUI opens.
    let read_pem = |path: &str| -> Result<Vec<u8>, ()> {
        std::fs::read(path).map_err(|e| {
            eprintln!("holger-ui: failed to read PEM file {path}: {e}");
        })
    };

    // mTLS client identity needs both --cert and --key together.
    let client_identity = match (cert_path.as_deref(), key_path.as_deref()) {
        (Some(c), Some(k)) => match (read_pem(c), read_pem(k)) {
            (Ok(cert), Ok(key)) => Some((cert, key)),
            _ => std::process::exit(1),
        },
        (None, None) => None,
        _ => {
            eprintln!("holger-ui: --cert and --key must be supplied together for mTLS");
            std::process::exit(1);
        }
    };
    let ca = match ca_path.as_deref() {
        Some(p) => match read_pem(p) {
            Ok(bytes) => Some(bytes),
            Err(()) => std::process::exit(1),
        },
        None => None,
    };

    // Any TLS material present → take the TLS path; otherwise the plain
    // (token-only) connect path.
    let use_tls = ca.is_some() || client_identity.is_some();
    let connect = if use_tls {
        UiData::connect_remote_with_tls(&endpoint, ca, client_identity, token.as_deref())
    } else {
        UiData::connect_remote_with_token(&endpoint, token.as_deref())
    };

    let data = match connect {
        Ok(d) => d,
        Err(e) => {
            eprintln!("holger-ui: failed to connect to holger at {endpoint}: {e:#}");
            std::process::exit(1);
        }
    };

    let native_options = eframe::NativeOptions {
        viewport: eframe::egui::ViewportBuilder::default()
            .with_inner_size([1100.0, 720.0])
            .with_min_inner_size([720.0, 480.0])
            .with_title("holger"),
        ..Default::default()
    };

    eframe::run_native(
        "holger-ui",
        native_options,
        Box::new(move |cc| {
            // Unified facett look & feel: the app installs the active preset's
            // full egui Style every frame (and publishes the derived legacy
            // palette so the facett Table facets re-theme coherently — COH-1).
            // Prime it once here so the first frame is already themed; replaces
            // the old per-crate `facett::Theme::sci_fi()`.
            let mut app = HolgerUiApp::new(data);
            // Hand the session's mTLS client identity to Tool #5 Airgap so an
            // auth=mtls populate reuses the connect-time cert/key.
            app.set_session_client_identity(
                cert_path.map(std::path::PathBuf::from),
                key_path.map(std::path::PathBuf::from),
            );
            app.apply_look(&cc.egui_ctx);
            Ok(Box::new(app))
        }),
    )
}

/// 🎬 `holger-ui --demo` — the self-running guided tour. Fills a dev znippy store
/// with real crates (offline-safe fixture fallback), wires it as an in-process
/// `LocalHolger` (no server, no gRPC), then launches the UI with the hands-free
/// walk armed so it drives every view with paced, animated beats.
fn run_demo(allow_network: bool) -> eframe::Result<()> {
    use std::sync::Arc;

    let store_dir = std::env::temp_dir().join(format!("holger-demo-store-{}", std::process::id()));
    let fill = match holger_ui::demo::fill_demo_store(&store_dir, allow_network) {
        Ok(f) => f,
        Err(e) => {
            eprintln!("holger-ui --demo: failed to fill demo store: {e:#}");
            std::process::exit(1);
        }
    };
    eprintln!(
        "holger-ui --demo: filled {} packages across {} repos ({}) → store {}",
        fill.packages.len(),
        fill.repos.len(),
        if fill.online { format!("{} real from crates.io", fill.real_count()) } else { "offline fixture".to_string() },
        store_dir.display(),
    );

    let local = fill.local_holger();
    let data = match UiData::new(Arc::new(local)) {
        Ok(d) => d,
        Err(e) => {
            eprintln!("holger-ui --demo: failed to build view-model: {e:#}");
            std::process::exit(1);
        }
    };

    let native_options = eframe::NativeOptions {
        viewport: eframe::egui::ViewportBuilder::default()
            .with_inner_size([1100.0, 720.0])
            .with_min_inner_size([720.0, 480.0])
            .with_title("holger — demo"),
        ..Default::default()
    };
    eframe::run_native(
        "holger-ui-demo",
        native_options,
        Box::new(move |cc| {
            let mut app = HolgerUiApp::new(data);
            // Beat spacing: $HOLGER_DEMO_BEAT_MS overrides (default 2200ms).
            let beat_ms = std::env::var("HOLGER_DEMO_BEAT_MS")
                .ok()
                .and_then(|s| s.parse::<u64>().ok())
                .unwrap_or(2200);
            app.enable_demo(beat_ms);
            app.apply_look(&cc.egui_ctx);
            Ok(Box::new(app))
        }),
    )
}