defter 0.1.0

Self-hosted Zotero sync and file storage server, with metadata in SQLite and attachments in S3.
//! Build the Svelte UI (`ui/`) with pnpm and embed `ui/dist/assets` in the
//! binary. Adapted from dragoman.

fn main() {
    build_ui();
    embed_assets();
}

fn build_ui() {
    for path in [
        "ui/src/",
        "ui/index.html",
        "ui/package.json",
        "ui/pnpm-lock.yaml",
        "ui/vite.config.js",
        "ui/svelte.config.js",
        // The login page's Tailwind classes are compiled into the UI's CSS.
        "src/ui.rs",
    ] {
        println!("cargo:rerun-if-changed={path}");
    }

    // pnpm may live under Homebrew on macOS; prepend the common prefixes.
    let path = std::env::var("PATH").unwrap_or_default();
    let path = format!("/opt/homebrew/bin:/usr/local/bin:{path}");

    // A packaged crate (from crates.io, or `cargo publish` verifying one)
    // ships the built ui/dist. Building there would write node_modules into a
    // source directory builds must not modify, so only a source checkout
    // builds the UI. Cargo adds Cargo.toml.orig to every package.
    let manifest_dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
    if manifest_dir.join("Cargo.toml.orig").exists() {
        if !manifest_dir.join("ui/dist/index.html").exists() {
            panic!("the package is missing ui/dist; it was published without a built UI");
        }
        return;
    }

    // Without pnpm, accept a dist built earlier.
    let pnpm_found = std::process::Command::new("pnpm")
        .arg("--version")
        .env("PATH", &path)
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false);
    if !pnpm_found {
        if !std::path::Path::new("ui/dist/index.html").exists() {
            panic!(
                "pnpm not found and ui/dist/index.html is missing; \
                 run: pnpm --dir ui install && pnpm --dir ui build"
            );
        }
        return;
    }

    let run = |args: &[&str]| {
        std::process::Command::new("pnpm")
            .args(args)
            .env("PATH", &path)
            .status()
            .map(|s| s.success())
            .unwrap_or(false)
    };
    let built =
        run(&["--dir", "ui", "install", "--frozen-lockfile"]) && run(&["--dir", "ui", "build"]);
    if !built {
        panic!("UI build failed; run manually: pnpm --dir ui install && pnpm --dir ui build");
    }
}

/// Write `$OUT_DIR/ui_assets.rs`: every file of `ui/dist/assets` as
/// `(name, bytes)`, served under `/assets/`. Vite names each by its content
/// hash, so a new build adds names rather than changing what an old one holds.
fn embed_assets() {
    let dir =
        std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("ui/dist/assets");
    let mut names: Vec<String> = std::fs::read_dir(&dir)
        .unwrap_or_else(|e| panic!("reading {}: {e}; build the UI first", dir.display()))
        .filter_map(|entry| entry.ok())
        .filter(|entry| entry.path().is_file())
        .map(|entry| entry.file_name().to_string_lossy().into_owned())
        .collect();
    names.sort();
    let mut out = String::from("pub static UI_ASSETS: &[(&str, &[u8])] = &[\n");
    for name in &names {
        out.push_str(&format!(
            "    ({name:?}, include_bytes!({:?})),\n",
            dir.join(name).display().to_string()
        ));
    }
    out.push_str("];\n");
    let dest = std::path::Path::new(&std::env::var("OUT_DIR").unwrap()).join("ui_assets.rs");
    std::fs::write(&dest, out).unwrap_or_else(|e| panic!("writing {}: {e}", dest.display()));
}