malvin 0.2.4

Non-interactive research and coding agent
mod copy;
mod npm;
mod pi_patch;
mod sync;

use std::env;
use std::fs;
use std::path::{Path, PathBuf};

use npm::{check_node_version, resolve_npm, run_npm};
use sync::sync_bridge_payload;

fn fnv1a64(data: &[u8]) -> u64 {
    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
    const PRIME: u64 = 0x0100_0000_01b3;
    let mut hash = OFFSET;
    for byte in data {
        hash ^= u64::from(*byte);
        hash = hash.wrapping_mul(PRIME);
    }
    hash
}

pub struct Bridge {
    pub dir_name: &'static str,
    pub package_marker: &'static str,
    pub label: &'static str,
    pub min_node: (u32, u32),
}

pub const BRIDGES: &[Bridge] = &[Bridge {
    dir_name: "cursor-sdk-bridge",
    package_marker: "@cursor/sdk/package.json",
    label: "Cursor SDK (@cursor/sdk)",
    min_node: (22, 13),
}];

pub fn run_build_script() {
    println!("cargo:rerun-if-env-changed=MALVIN_SKIP_SDK_BRIDGES");
    println!("cargo:rerun-if-env-changed=DOCS_RS");
    println!("cargo:rerun-if-env-changed=HOME");

    let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
    pi_patch::apply_pi_openrouter_cost_patch(&manifest_dir);
    emit_rerun_if_changed(&manifest_dir);

    if env::var_os("DOCS_RS").is_some() {
        return;
    }
    if env::var_os("MALVIN_SKIP_SDK_BRIDGES").is_some() {
        println!(
            "cargo:warning=MALVIN_SKIP_SDK_BRIDGES is set; Cursor SDK bridge was not installed"
        );
        return;
    }

    for bridge in BRIDGES {
        ensure_bridge(&manifest_dir, bridge);
    }
}

fn emit_rerun_if_changed(manifest_dir: &Path) {
    for bridge in BRIDGES {
        let dir = manifest_dir.join(bridge.dir_name);
        println!(
            "cargo:rerun-if-changed={}",
            dir.join("package.json").display()
        );
        println!(
            "cargo:rerun-if-changed={}",
            dir.join("package-lock.json").display()
        );
        println!(
            "cargo:rerun-if-changed={}",
            dir.join("tsconfig.json").display()
        );
        if let Ok(entries) = fs::read_dir(dir.join("src")) {
            for entry in entries.flatten() {
                println!("cargo:rerun-if-changed={}", entry.path().display());
            }
        }
    }
}

pub fn ensure_bridge(manifest_dir: &Path, bridge: &Bridge) {
    let src = manifest_dir.join(bridge.dir_name);
    assert!(
        src.join("package.json").is_file(),
        "missing {}; the malvin crate requires the {} bridge sources",
        src.join("package.json").display(),
        bridge.label
    );

    if in_tree_bridge_ready(&src, bridge) {
        return;
    }

    let dest = sdk_share_dir().join(bridge.dir_name);
    sync_bridge_payload(&src, &dest, bridge.dir_name);
    if share_bridge_ready(&src, &dest, bridge) {
        return;
    }

    install_npm_deps(&dest, bridge);
}

fn in_tree_bridge_ready(src: &Path, bridge: &Bridge) -> bool {
    src.join("node_modules")
        .join(bridge.package_marker)
        .is_file()
        && src.join("dist").join("bridge.js").is_file()
}

fn share_bridge_ready(src: &Path, dest: &Path, bridge: &Bridge) -> bool {
    let marker = dest.join("node_modules").join(bridge.package_marker);
    let dist_js = dest.join("dist").join("bridge.js");
    let stamp_path = dest.join(".malvin-npm-stamp");
    let lock_bytes = fs::read(src.join("package-lock.json")).unwrap_or_default();
    let lock_stamp = format!("{:x}", fnv1a64(&lock_bytes));
    marker.is_file()
        && dist_js.is_file()
        && fs::read_to_string(&stamp_path).unwrap_or_default().trim() == lock_stamp
}

fn install_npm_deps(dest: &Path, bridge: &Bridge) {
    let npm = resolve_npm();
    check_node_version(bridge);
    eprintln!(
        "malvin: installing {} into {}",
        bridge.label,
        dest.display()
    );
    let dist_js = dest.join("dist").join("bridge.js");
    if dist_js.is_file() {
        run_npm(&npm, dest, &["ci", "--omit=dev"]);
    } else {
        run_npm(&npm, dest, &["ci"]);
        eprintln!(
            "malvin: building {} bridge (npm run build)…",
            bridge.dir_name
        );
        run_npm(&npm, dest, &["run", "build"]);
    }
    verify_install(dest, bridge);
    write_stamp(dest);
}

fn verify_install(dest: &Path, bridge: &Bridge) {
    let marker = dest.join("node_modules").join(bridge.package_marker);
    assert!(
        marker.is_file(),
        "after npm ci, {} is missing under {}. \
         Install Node >= {}.{}, ensure network access for npm, then retry.",
        bridge.package_marker,
        dest.display(),
        bridge.min_node.0,
        bridge.min_node.1
    );
    let dist_js = dest.join("dist").join("bridge.js");
    assert!(
        dist_js.is_file(),
        "after npm install, {} is missing; bridge TypeScript build failed",
        dist_js.display()
    );
}

fn write_stamp(dest: &Path) {
    let lock = dest.join("package-lock.json");
    let lock_bytes = fs::read(&lock).unwrap_or_default();
    let stamp_path = dest.join(".malvin-npm-stamp");
    fs::write(&stamp_path, format!("{:x}\n", fnv1a64(&lock_bytes))).unwrap_or_else(|e| {
        panic!("failed to write {}: {e}", stamp_path.display());
    });
}

pub fn sdk_share_dir() -> PathBuf {
    let home = env::var_os("HOME").filter(|v| !v.is_empty()).unwrap_or_else(|| {
        panic!(
            "malvin requires $HOME to install Cursor/Pi bridges under ~/.malvin_home/sdk-bridges/"
        )
    });
    PathBuf::from(home).join(".malvin_home").join("sdk-bridges")
}

#[cfg(test)]
mod tests;