wsb 0.164.718

Workspace - A tool suite for file operations, version management, and development workflow automation
use std::fs;
use std::io;
use std::path::Path;
use std::process::{Command, Stdio};

fn main() {
    println!("cargo:rerun-if-changed=build.rs");
    println!("cargo:rerun-if-changed=frontend/index.html");
    println!("cargo:rerun-if-changed=frontend/package.json");
    println!("cargo:rerun-if-changed=frontend/package-lock.json");
    println!("cargo:rerun-if-changed=frontend/svelte.config.js");
    println!("cargo:rerun-if-changed=frontend/tsconfig.json");
    println!("cargo:rerun-if-changed=frontend/vite.config.ts");
    println!("cargo:rerun-if-changed=frontend/public");
    println!("cargo:rerun-if-changed=frontend/src");
    println!("cargo:rerun-if-changed=migrations");

    let frontend_dir = Path::new("frontend");
    let dist_index = frontend_dir.join("dist").join("index.html");
    let frontend_src = frontend_dir.join("src");

    // When installing from crates.io, only frontend/dist/** is included in the
    // published package — frontend/src and the npm toolchain are absent.
    // In that case skip hashing and building; the pre-built dist is already there.
    let prebuilt = dist_index.is_file() && !frontend_src.is_dir();

    let backend_build_id = hash_paths(&["build.rs", "Cargo.toml", "Cargo.lock", "src"])
        .unwrap_or_else(|error| panic!("failed to hash backend build inputs: {}", error));
    let frontend_build_id = if prebuilt {
        // Stable sentinel so the env var is always set.
        "prebuilt".to_string()
    } else {
        hash_paths_existing(&[
            "frontend/package.json",
            "frontend/package-lock.json",
            "frontend/svelte.config.js",
            "frontend/tsconfig.json",
            "frontend/vite.config.ts",
            "frontend/public",
            "frontend/src",
        ])
        .unwrap_or_else(|error| panic!("failed to hash frontend build inputs: {}", error))
    };
    println!("cargo:rustc-env=WSB_BACKEND_BUILD_ID={}", backend_build_id);
    println!(
        "cargo:rustc-env=WSB_FRONTEND_BUILD_ID={}",
        frontend_build_id
    );

    if prebuilt {
        // Pre-built dist is embedded as-is; nothing more to do.
        return;
    }

    let package_json = frontend_dir.join("package.json");
    let package_lock = frontend_dir.join("package-lock.json");
    let node_modules_dir = frontend_dir.join("node_modules");
    let install_stamp = node_modules_dir.join(".wsb-package-lock");

    if !frontend_dir.is_dir() {
        panic!("frontend/ directory not found");
    }
    if !package_json.is_file() {
        panic!("frontend/package.json not found");
    }

    let install_subcommand = if package_lock.is_file() {
        "ci"
    } else {
        panic!("frontend/package-lock.json not found; locked frontend dependencies are required");
    };

    let package_lock_contents = fs::read(&package_lock)
        .unwrap_or_else(|error| panic!("failed to read {}: {}", package_lock.display(), error));
    let install_stamp_contents = fs::read(&install_stamp).ok();
    let dependencies_ready = node_modules_dir.is_dir()
        && install_stamp_contents
            .as_ref()
            .is_some_and(|bytes| bytes.as_slice() == package_lock_contents.as_slice());

    if !dependencies_ready {
        run_npm(
            frontend_dir,
            &[install_subcommand],
            "frontend dependency install",
            &frontend_build_id,
        );
        fs::write(&install_stamp, &package_lock_contents).unwrap_or_else(|error| {
            panic!(
                "failed to write frontend dependency stamp {}: {}",
                install_stamp.display(),
                error
            )
        });
    }

    run_npm(
        frontend_dir,
        &["run", "build"],
        "frontend production build",
        &frontend_build_id,
    );

    if !dist_index.is_file() {
        panic!("frontend build completed without producing frontend/dist/index.html");
    }
}

fn run_npm(frontend_dir: &Path, args: &[&str], phase: &str, build_id: &str) {
    let mut command = Command::new("npm");
    command
        .args(args)
        .current_dir(frontend_dir)
        .env("WSB_FRONTEND_BUILD_ID", build_id)
        .stdin(Stdio::null())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit());
    let status = command.status().unwrap_or_else(|error| {
        panic!(
            "failed to start npm for {} in {}: {}",
            phase,
            frontend_dir.display(),
            error
        )
    });

    if !status.success() {
        panic!(
            "npm {} failed during {} in {}",
            args.join(" "),
            phase,
            frontend_dir.display()
        );
    }
}

fn hash_paths(paths: &[&str]) -> io::Result<String> {
    let mut hasher = Fnv1a64::new();
    for path in paths {
        hash_path(Path::new(path), &mut hasher)?;
    }
    Ok(hasher.finish_hex())
}

/// Like hash_paths but silently skips paths that don't exist.
fn hash_paths_existing(paths: &[&str]) -> io::Result<String> {
    let mut hasher = Fnv1a64::new();
    for path in paths {
        let p = Path::new(path);
        if p.exists() {
            hash_path(p, &mut hasher)?;
        }
    }
    Ok(hasher.finish_hex())
}

fn hash_path(path: &Path, hasher: &mut Fnv1a64) -> io::Result<()> {
    let metadata = fs::metadata(path)?;
    if metadata.is_dir() {
        let mut entries = fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
        entries.sort_by(|left, right| left.path().cmp(&right.path()));
        hasher.write(path.to_string_lossy().as_bytes());
        hasher.write(b"\0dir\0");
        for entry in entries {
            hash_path(&entry.path(), hasher)?;
        }
        return Ok(());
    }

    hasher.write(path.to_string_lossy().as_bytes());
    hasher.write(b"\0file\0");
    hasher.write(&fs::read(path)?);
    hasher.write(b"\0");
    Ok(())
}

struct Fnv1a64(u64);

impl Fnv1a64 {
    const OFFSET_BASIS: u64 = 0xcbf29ce484222325;
    const PRIME: u64 = 0x00000100000001B3;

    fn new() -> Self {
        Self(Self::OFFSET_BASIS)
    }

    fn write(&mut self, bytes: &[u8]) {
        for byte in bytes {
            self.0 ^= u64::from(*byte);
            self.0 = self.0.wrapping_mul(Self::PRIME);
        }
    }

    fn finish_hex(&self) -> String {
        format!("{:016x}", self.0)
    }
}