claude-code-transcripts-ingest 0.1.8

CLI that ingests Claude Code transcript JSONL files into a DuckDB database for usage / cost analysis.
use std::path::{Path, PathBuf};
use std::process::Command;

fn main() {
    let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
    let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
    let web_src = manifest_dir.join("web");
    // All npm operations run from OUT_DIR/web so writes stay inside OUT_DIR.
    let web_out = out_dir.join("web");
    let web_dist_out = web_out.join("dist");

    for f in [
        "web/src",
        "web/package.json",
        "web/package-lock.json",
        "web/index.html",
        "web/vite.config.ts",
        "web/tsconfig.json",
    ] {
        println!("cargo:rerun-if-changed={f}");
    }
    println!("cargo:rerun-if-env-changed=SKIP_WEB_BUILD");

    if std::env::var_os("SKIP_WEB_BUILD").is_some() {
        if !web_dist_out.join("index.html").exists() {
            copy_dist(&web_src.join("dist"), &web_dist_out);
        }
        return;
    }

    let npm = which("npm");
    if npm.is_none() {
        println!("cargo:warning=npm not found; using prebuilt web/dist (set SKIP_WEB_BUILD=1 to silence)");
        if !web_dist_out.join("index.html").exists() {
            copy_dist(&web_src.join("dist"), &web_dist_out);
        }
        return;
    }
    let npm = npm.unwrap();

    sync_web_src(&web_src, &web_out);

    if !web_out.join("node_modules").exists() {
        run(&npm, &["ci", "--silent"], &web_out, &[]);
    }

    run(
        &npm,
        &["run", "build", "--silent"],
        &web_out,
        &[("VITE_OUT_DIR", web_dist_out.to_str().unwrap())],
    );
    ensure_dist_exists(&web_dist_out);
}

// Copy web source files into dst, skipping build artifacts so all npm
// operations run inside OUT_DIR and never touch the source tree.
fn sync_web_src(src: &Path, dst: &Path) {
    std::fs::create_dir_all(dst).unwrap_or_else(|e| panic!("create {dst:?}: {e}"));
    for entry in std::fs::read_dir(src).unwrap_or_else(|e| panic!("readdir {src:?}: {e}")) {
        let entry = entry.unwrap();
        let name = entry.file_name();
        if matches!(
            name.to_str(),
            Some("node_modules") | Some("dist") | Some(".vite")
        ) {
            continue;
        }
        let dst_path = dst.join(&name);
        if entry.file_type().unwrap().is_dir() {
            copy_dir_all(&entry.path(), &dst_path)
                .unwrap_or_else(|e| panic!("copy dir {:?}: {e}", entry.path()));
        } else {
            std::fs::copy(entry.path(), &dst_path)
                .unwrap_or_else(|e| panic!("copy {:?}: {e}", entry.path()));
        }
    }
}

fn copy_dist(src: &Path, dst: &Path) {
    if !src.join("index.html").exists() {
        panic!(
            "web/dist/index.html missing at {}. Run `npm --prefix web run build` first.",
            src.display()
        );
    }
    copy_dir_all(src, dst).unwrap_or_else(|e| panic!("copy {src:?} -> {dst:?}: {e}"));
}

fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
    std::fs::create_dir_all(dst)?;
    for entry in std::fs::read_dir(src)? {
        let entry = entry?;
        let dst_path = dst.join(entry.file_name());
        if entry.file_type()?.is_dir() {
            copy_dir_all(&entry.path(), &dst_path)?;
        } else {
            std::fs::copy(entry.path(), dst_path)?;
        }
    }
    Ok(())
}

fn ensure_dist_exists(dist: &Path) {
    let idx = dist.join("index.html");
    if !idx.exists() {
        panic!("web/dist/index.html missing at {}", idx.display());
    }
}

fn which(cmd: &str) -> Option<PathBuf> {
    let path = std::env::var_os("PATH")?;
    for dir in std::env::split_paths(&path) {
        let full = dir.join(cmd);
        if full.is_file() {
            return Some(full);
        }
    }
    None
}

fn run(cmd: &Path, args: &[&str], cwd: &Path, env: &[(&str, &str)]) {
    let mut command = Command::new(cmd);
    command.args(args).current_dir(cwd);
    for (k, v) in env {
        command.env(k, v);
    }
    let status = command
        .status()
        .unwrap_or_else(|e| panic!("run {} {:?}: {e}", cmd.display(), args));
    if !status.success() {
        panic!("{} {:?} failed: {status}", cmd.display(), args);
    }
}