noi-build 0.0.1

Build helper that runs `nargo export` and stages artifacts for noi
Documentation
use std::{
    env,
    path::{Path, PathBuf},
    process::Command,
};

use anyhow::{anyhow, Context, Result};
use fs_err as fs;
use walkdir::WalkDir;

const EXPORT_SUBDIR: &str = "noi_exports";

#[derive(Debug, Clone)]
pub struct BuildOptions {
    pub program_dir: PathBuf,
    pub out_dir: PathBuf,
}

#[derive(Debug, Clone)]
pub struct ExportSummary {
    pub staged_dir: PathBuf,
    pub artifacts: Vec<PathBuf>,
}

pub fn export(opts: &BuildOptions) -> Result<ExportSummary> {
    ensure_program_dir(&opts.program_dir)?;
    let stage_dir = opts.out_dir.join(EXPORT_SUBDIR);
    if stage_dir.exists() {
        fs::remove_dir_all(&stage_dir).with_context(|| {
            format!(
                "failed to clear previous exports at `{}`",
                stage_dir.display()
            )
        })?;
    }
    fs::create_dir_all(&stage_dir)?;

    let run_result = run_nargo(&opts.program_dir);
    emit_cargo_metadata(&opts.program_dir, &stage_dir);

    let staged = match stage_exports(&opts.program_dir, &stage_dir) {
        Ok(result) => {
            if let StageSource::Fallback(ref path) = result.source {
                println!(
                    "cargo:warning=Using pre-exported Noir artifacts from {}",
                    path.display()
                );
                if let Err(err) = &run_result {
                    verbose(&format!("nargo export skipped: {err}"));
                }
            }
            result
        }
        Err(stage_err) => {
            if let Err(run_err) = run_result {
                return Err(stage_err.context(run_err));
            }
            return Err(stage_err);
        }
    };

    Ok(ExportSummary {
        staged_dir: stage_dir,
        artifacts: staged.artifacts,
    })
}

fn ensure_program_dir(path: &Path) -> Result<()> {
    if !path.exists() {
        return Err(anyhow!(
            "program directory `{}` does not exist",
            path.display()
        ));
    }
    if !path.join("Nargo.toml").exists() {
        return Err(anyhow!(
            "`{}` is missing Nargo.toml; point BuildOptions::program_dir at a Noir workspace",
            path.display()
        ));
    }
    Ok(())
}

fn run_nargo(program_dir: &Path) -> Result<()> {
    let bin = env::var("NOI_NARGO_BIN").unwrap_or_else(|_| "nargo".into());
    verbose(&format!(
        "running `{bin} export` in {}",
        program_dir.display()
    ));

    let status = Command::new(&bin)
        .arg("export")
        .current_dir(program_dir)
        .status()
        .with_context(|| format!("failed to start `{bin}`"))?;

    if !status.success() {
        return Err(anyhow!("`{bin} export` failed with status {status}"));
    }

    Ok(())
}

fn stage_exports(program_dir: &Path, dest_dir: &Path) -> Result<StageResult> {
    let target_root = program_dir.join("target");
    if target_root.exists() {
        let staged = copy_artifacts(&target_root, dest_dir)?;
        if !staged.is_empty() {
            verbose(&format!(
                "staged {} export(s) into {}",
                staged.len(),
                dest_dir.display()
            ));
            return Ok(StageResult {
                artifacts: staged,
                source: StageSource::Target,
            });
        }
    }

    let fallback = program_dir.join("exports");
    if fallback.exists() {
        let staged = copy_artifacts(&fallback, dest_dir)?;
        if !staged.is_empty() {
            verbose(&format!(
                "staged {} fallback export(s) into {}",
                staged.len(),
                dest_dir.display()
            ));
            return Ok(StageResult {
                artifacts: staged,
                source: StageSource::Fallback(fallback),
            });
        }
    }

    Err(anyhow!(
        "no JSON artifacts found in `{}` (expected either `target/` or `exports/`)",
        program_dir.display()
    ))
}

fn copy_artifacts(root: &Path, dest_dir: &Path) -> Result<Vec<PathBuf>> {
    let mut staged = Vec::new();
    for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) {
        if !entry.file_type().is_file() {
            continue;
        }
        if entry
            .path()
            .extension()
            .and_then(|ext| ext.to_str())
            .map(|ext| ext != "json")
            .unwrap_or(true)
        {
            continue;
        }

        let rel = entry
            .path()
            .strip_prefix(root)
            .unwrap_or_else(|_| entry.path());
        let dest = dest_dir.join(rel);
        if let Some(parent) = dest.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::copy(entry.path(), &dest).with_context(|| {
            format!(
                "failed to copy `{}` to `{}`",
                entry.path().display(),
                dest.display()
            )
        })?;
        staged.push(dest);
    }

    Ok(staged)
}

struct StageResult {
    artifacts: Vec<PathBuf>,
    source: StageSource,
}

enum StageSource {
    Target,
    Fallback(PathBuf),
}

fn emit_cargo_metadata(program_dir: &Path, stage_dir: &Path) {
    println!("cargo:rustc-env=NOI_EXPORT_DIR={}", stage_dir.display());
    println!(
        "cargo:rerun-if-changed={}",
        program_dir.join("Nargo.toml").display()
    );

    let src_dir = program_dir.join("src");
    if src_dir.exists() {
        for entry in WalkDir::new(&src_dir).into_iter().filter_map(|e| e.ok()) {
            if entry.file_type().is_file() {
                println!("cargo:rerun-if-changed={}", entry.path().display());
            }
        }
    }
}

fn verbose(msg: &str) {
    if env::var("NOI_VERBOSE").as_deref() == Ok("1") {
        eprintln!("[noi-build] {msg}");
    }
}