xagent-pi 0.2.10

Self-contained local brain (chat UI + API + SSE) for the Pi agent, tunneled into xagent-service.
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

fn main() {
    let crate_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("manifest directory"));
    let source_web_dir = crate_dir.join("web");
    let output = PathBuf::from(env::var_os("OUT_DIR").expect("build output directory"));
    let web_dir = output.join("web-build");
    let dist_dir = web_dir.join("dist");

    for path in [
        "package.json",
        "package-lock.json",
        "index.html",
        "vite.config.ts",
        "tsconfig.json",
        "tsconfig.app.json",
        "tsconfig.node.json",
        "src",
        "public",
    ] {
        println!("cargo:rerun-if-changed={}", source_web_dir.join(path).display());
        copy_path(&source_web_dir.join(path), &web_dir.join(path));
    }

    if !web_dir.join("node_modules").is_dir() {
        run(&web_dir, "npm", &["ci"]);
    }
    run(&web_dir, "npm", &["run", "build"]);

    let mut files = Vec::new();
    collect_files(&dist_dir, &dist_dir, &mut files);
    files.sort_by(|left, right| left.0.cmp(&right.0));
    assert!(
        files.iter().any(|(path, _)| path == "index.html"),
        "frontend build did not produce index.html"
    );

    let mut generated = String::from(
        "pub fn embedded_asset(path: &str) -> Option<EmbeddedAsset> {\n    match path {\n",
    );
    for (relative, absolute) in files {
        generated.push_str(&format!(
            "        {:?} => Some(EmbeddedAsset {{ bytes: &include_bytes!({:?})[..], content_type: {:?} }}),\n",
            relative,
            absolute,
            content_type(&relative),
        ));
    }
    generated.push_str("        _ => None,\n    }\n}\n");

    fs::write(output.join("embedded_web.rs"), generated).expect("write embedded asset table");
}

fn copy_path(source: &Path, destination: &Path) {
    if source.is_dir() {
        fs::create_dir_all(destination).expect("create frontend build directory");
        for entry in fs::read_dir(source).expect("read frontend source directory") {
            let entry = entry.expect("read frontend source entry");
            copy_path(&entry.path(), &destination.join(entry.file_name()));
        }
    } else {
        fs::create_dir_all(destination.parent().expect("frontend destination parent"))
            .expect("create frontend destination parent");
        fs::copy(source, destination).expect("copy frontend build input");
    }
}

fn run(cwd: &Path, program: &str, args: &[&str]) {
    let status = Command::new(program)
        .args(args)
        .current_dir(cwd)
        .status()
        .unwrap_or_else(|error| panic!("failed to run {program}: {error}"));
    assert!(status.success(), "{program} {} failed", args.join(" "));
}

fn collect_files(root: &Path, directory: &Path, output: &mut Vec<(String, String)>) {
    for entry in fs::read_dir(directory).expect("read frontend build directory") {
        let entry = entry.expect("read frontend build entry");
        let path = entry.path();
        if path.is_dir() {
            collect_files(root, &path, output);
        } else {
            let relative = path
                .strip_prefix(root)
                .expect("asset below build directory")
                .to_string_lossy()
                .replace('\\', "/");
            output.push((
                relative,
                path.canonicalize()
                    .expect("canonical asset path")
                    .to_string_lossy()
                    .into_owned(),
            ));
        }
    }
}

fn content_type(path: &str) -> &'static str {
    match Path::new(path)
        .extension()
        .and_then(|extension| extension.to_str())
        .unwrap_or("")
    {
        "html" => "text/html; charset=utf-8",
        "js" => "text/javascript; charset=utf-8",
        "css" => "text/css; charset=utf-8",
        "json" => "application/json",
        "svg" => "image/svg+xml",
        "png" => "image/png",
        "jpg" | "jpeg" => "image/jpeg",
        "webp" => "image/webp",
        "ico" => "image/x-icon",
        "woff" => "font/woff",
        "woff2" => "font/woff2",
        "ttf" => "font/ttf",
        _ => "application/octet-stream",
    }
}