mockforge-ui 0.3.78

Admin UI for MockForge - web-based interface for managing mock servers
Documentation
use serde::Deserialize;
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

#[derive(Deserialize, Debug)]
struct ManifestEntry {
    file: String,
    css: Option<Vec<String>>,
}

const PLACEHOLDER_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>MockForge Admin UI</title>
<style>body{font-family:system-ui,sans-serif;max-width:600px;margin:60px auto;padding:0 20px;color:#333}
h1{color:#1a1a1a}pre{background:#f4f4f4;padding:12px;border-radius:4px;overflow-x:auto}</style>
</head>
<body>
<h1>MockForge Admin UI Not Available</h1>
<p>The admin UI assets were not included in this build. The mock server is fully functional — only the web dashboard is unavailable.</p>
<p>To get the admin UI, build from source:</p>
<pre>git clone https://github.com/SaaSy-Solutions/mockforge
cd mockforge/crates/mockforge-ui/ui
pnpm install && pnpm build
cd ../../..
cargo install --path crates/mockforge-cli</pre>
</body>
</html>"#;

fn main() {
    // Generate version information using vergen
    if let Err(e) = vergen::EmitBuilder::builder().build_timestamp().git_sha(true).emit() {
        println!("cargo:warning=Failed to generate version info: {}", e);
    }
    println!("cargo:rerun-if-changed=ui/build.rs");
    println!("cargo:rerun-if-changed=ui/src/");

    let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
    let ui_dist_path = Path::new(&crate_dir).join("ui/dist");
    let ui_public_path = Path::new(&crate_dir).join("ui/public");

    // Ensure dist directory exists (for local dev builds)
    if !ui_dist_path.exists() {
        fs::create_dir_all(&ui_dist_path).expect("Failed to create ui/dist directory");
    }

    // Try to run the UI build script, but don't fail if it doesn't exist or fails
    // This allows the crate to compile even when the UI hasn't been built
    let ui_build_script = Path::new(&crate_dir).join("build_ui.sh");
    if ui_build_script.exists() {
        let status = Command::new("bash").arg(&ui_build_script).status();

        if let Ok(status) = status {
            if !status.success() {
                println!(
                    "cargo:warning=UI build script failed, but continuing with fallback files"
                );
            }
        } else {
            println!(
                "cargo:warning=Failed to run UI build script, but continuing with fallback files"
            );
        }
    } else {
        println!("cargo:warning=UI build script not found, using fallback files from public/");
    }

    println!("cargo:rerun-if-changed={}", ui_dist_path.join("index.html").display());
    println!("cargo:rerun-if-changed={}", ui_dist_path.join("manifest.json").display());
    println!("cargo:rerun-if-changed={}", ui_dist_path.join("assets").display());
    println!("cargo:rerun-if-changed={}", ui_public_path.join("manifest.json").display());
    println!("cargo:rerun-if-changed={}", ui_public_path.join("sw.js").display());

    let out_dir = env::var_os("OUT_DIR").unwrap();
    let out_path = Path::new(&out_dir);

    generate_ui_content(out_path, &ui_dist_path);
    generate_asset_paths(out_path, &ui_dist_path);
    generate_icon_assets(out_path, &ui_public_path);
}

/// Resolve CSS and JS paths by checking the Vite manifest first, then falling back to
/// well-known paths under `ui/dist/assets/`. Each file is checked individually.
fn resolve_css_js_paths(ui_dist_path: &Path) -> (Option<PathBuf>, Option<PathBuf>) {
    let mut css_path = None;
    let mut js_path = None;

    // Try Vite build manifest first (has hashed filenames)
    let vite_manifest = ui_dist_path.join("manifest.json");
    if vite_manifest.exists() {
        if let Ok(content) = fs::read_to_string(&vite_manifest) {
            if let Ok(manifest) = serde_json::from_str::<HashMap<String, ManifestEntry>>(&content) {
                if let Some(entry) = manifest.get("index.html") {
                    let jp = ui_dist_path.join(&entry.file);
                    if jp.exists() {
                        js_path = Some(jp);
                    }
                    if let Some(css_files) = &entry.css {
                        if let Some(first) = css_files.first() {
                            let cp = ui_dist_path.join(first);
                            if cp.exists() {
                                css_path = Some(cp);
                            }
                        }
                    }
                }
            }
        }
    }

    // Fallback to well-known direct paths
    if css_path.is_none() {
        let fallback = ui_dist_path.join("assets/index.css");
        if fallback.exists() {
            css_path = Some(fallback);
        }
    }
    if js_path.is_none() {
        let fallback = ui_dist_path.join("assets/index.js");
        if fallback.exists() {
            js_path = Some(fallback);
        }
    }

    (css_path, js_path)
}

/// Generate `ui_content.rs` with `get_admin_html()`, `get_admin_css()`, `get_admin_js()`.
/// Each function embeds real assets if available, or placeholder content if not.
fn generate_ui_content(out_path: &Path, ui_dist_path: &Path) {
    let dest = out_path.join("ui_content.rs");
    let mut code = String::new();

    // get_admin_html()
    let index_html = ui_dist_path.join("index.html");
    if index_html.exists() {
        code.push_str(&format!(
            "pub fn get_admin_html() -> &'static str {{ include_str!(r\"{}\") }}\n\n",
            index_html.display()
        ));
    } else {
        // Write placeholder HTML to a file so we can include_str! it
        let placeholder_path = out_path.join("placeholder_admin.html");
        fs::write(&placeholder_path, PLACEHOLDER_HTML).unwrap();
        code.push_str(&format!(
            "pub fn get_admin_html() -> &'static str {{ include_str!(r\"{}\") }}\n\n",
            placeholder_path.display()
        ));
    }

    // get_admin_css() and get_admin_js()
    let (css_path, js_path) = resolve_css_js_paths(ui_dist_path);

    if let Some(p) = css_path {
        code.push_str(&format!(
            "pub fn get_admin_css() -> &'static str {{ include_str!(r\"{}\") }}\n\n",
            p.display()
        ));
    } else {
        code.push_str("pub fn get_admin_css() -> &'static str { \"/* UI not built */\" }\n\n");
    }

    if let Some(p) = js_path {
        code.push_str(&format!(
            "pub fn get_admin_js() -> &'static str {{ include_str!(r\"{}\") }}\n\n",
            p.display()
        ));
    } else {
        code.push_str("pub fn get_admin_js() -> &'static str { \"// UI not built\" }\n\n");
    }

    fs::write(&dest, code).unwrap();
}

/// Generate `asset_paths.rs` with only `get_asset_map()` for serving hashed vendor assets.
fn generate_asset_paths(out_path: &Path, ui_dist_path: &Path) {
    let dest = out_path.join("asset_paths.rs");
    let assets_dir = ui_dist_path.join("assets");

    let mut code = String::from(
        "pub fn get_asset_map() -> std::collections::HashMap<&'static str, &'static str> {\n",
    );
    code.push_str("    let mut map = std::collections::HashMap::new();\n");

    if assets_dir.exists() {
        if let Ok(entries) = fs::read_dir(&assets_dir) {
            for entry in entries.flatten() {
                if let Some(filename) = entry.path().file_name().and_then(|n| n.to_str()) {
                    if filename.ends_with(".js") || filename.ends_with(".css") {
                        let asset_path = entry.path();
                        code.push_str(&format!(
                            "    map.insert(\"{}\", include_str!(r\"{}\"));\n",
                            filename,
                            asset_path.display()
                        ));
                    }
                }
            }
        }
    }

    code.push_str("    map\n}\n");
    fs::write(&dest, code).unwrap();
}

/// Generate `icon_assets.rs` with embedded icon/logo assets.
fn generate_icon_assets(out_path: &Path, ui_public_path: &Path) {
    let icon_assets_path = out_path.join("icon_assets.rs");
    let mut icon_assets = String::from("// Embedded icon/logo assets\n");

    let icon_files = vec![
        ("ICON_DEFAULT", "mockforge-icon.png"),
        ("ICON_32", "mockforge-icon-32.png"),
        ("ICON_48", "mockforge-icon-48.png"),
        ("LOGO_40", "mockforge-logo-40.png"),
        ("LOGO_80", "mockforge-logo-80.png"),
    ];

    for (const_name, filename) in icon_files {
        let icon_path = ui_public_path.join(filename);
        if icon_path.exists() {
            match fs::read(&icon_path) {
                Ok(bytes) => {
                    let mut byte_array = String::from("&[");
                    for (i, byte) in bytes.iter().enumerate() {
                        if i > 0 {
                            byte_array.push_str(", ");
                        }
                        if i % 20 == 0 && i > 0 {
                            byte_array.push_str("\n        ");
                        }
                        byte_array.push_str(&format!("0x{:02X}", byte));
                    }
                    byte_array.push(']');
                    icon_assets
                        .push_str(&format!("pub const {}: &[u8] = {};\n", const_name, byte_array));
                }
                Err(_) => {
                    icon_assets.push_str(&format!("pub const {}: &[u8] = &[];\n", const_name));
                }
            }
        } else {
            icon_assets.push_str(&format!("pub const {}: &[u8] = &[];\n", const_name));
        }
    }

    fs::write(&icon_assets_path, icon_assets).unwrap();
}