node-app-build 6.3.0

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! Cross-compilation target model + command builders for native and
//! standalone packaging. Pure where possible: every external invocation is
//! produced by a `*_command` builder returning `(program, args)`, so unit
//! tests assert arguments without running cargo/cross/bun.

use anyhow::{bail, Result};
use std::path::{Path, PathBuf};

/// A Debian target architecture the CLI can produce native/standalone debs for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Arch {
    Arm64,
    Amd64,
}

// The full `Arch` API is consumed by later cross-build tasks (command builders
// + packaging); this module currently only exercises it from unit tests.
#[allow(dead_code)]
impl Arch {
    /// Rust target triple for `cargo`/`cross --target`.
    pub fn rust_triple(self) -> &'static str {
        match self {
            Arch::Arm64 => "aarch64-unknown-linux-gnu",
            Arch::Amd64 => "x86_64-unknown-linux-gnu",
        }
    }

    /// Bun `--target` value for `bun build --compile`.
    pub fn bun_target(self) -> &'static str {
        match self {
            Arch::Arm64 => "bun-linux-arm64",
            Arch::Amd64 => "bun-linux-x64",
        }
    }

    /// Debian architecture string for the `.deb` filename + control file.
    pub fn deb_arch(self) -> &'static str {
        match self {
            Arch::Arm64 => "arm64",
            Arch::Amd64 => "amd64",
        }
    }

    /// Parse from a Debian arch string (the `--arch` flag value).
    pub fn from_deb_arch(s: &str) -> Result<Arch> {
        match s {
            "arm64" => Ok(Arch::Arm64),
            "amd64" => Ok(Arch::Amd64),
            other => bail!(
                "unsupported --arch '{}' for native/standalone apps (use arm64 or amd64)",
                other
            ),
        }
    }

    /// The host's arch, if it maps to a supported target.
    pub fn host() -> Option<Arch> {
        match std::env::consts::ARCH {
            "aarch64" => Some(Arch::Arm64),
            "x86_64" => Some(Arch::Amd64),
            _ => None,
        }
    }
}

// These command builders + path helpers are consumed by later cross-build
// tasks (the `build`/`package` steps); this module currently only exercises
// them from unit tests.
#[allow(dead_code)]
/// Whether to invoke `cross` (target ≠ host) rather than `cargo`.
pub fn needs_cross(arch: Arch) -> bool {
    Arch::host() != Some(arch)
}

#[allow(dead_code)]
/// `cargo build`/`cross build` for a release cdylib, cross-compiled to `arch`.
pub fn cargo_cdylib_command(arch: Arch, use_cross: bool) -> (String, Vec<String>) {
    let prog = if use_cross { "cross" } else { "cargo" }.to_string();
    let args = vec![
        "build".into(),
        "--release".into(),
        "--target".into(),
        arch.rust_triple().into(),
    ];
    (prog, args)
}

#[allow(dead_code)]
/// `cargo build`/`cross build` for a release binary named `bin`.
pub fn cargo_bin_command(arch: Arch, use_cross: bool, bin: &str) -> (String, Vec<String>) {
    let prog = if use_cross { "cross" } else { "cargo" }.to_string();
    let args = vec![
        "build".into(),
        "--release".into(),
        "--target".into(),
        arch.rust_triple().into(),
        "--bin".into(),
        bin.into(),
    ];
    (prog, args)
}

#[allow(dead_code)]
/// `bun build --compile` producing a single self-contained binary at `outfile`.
pub fn bun_compile_command(arch: Arch, entry: &str, outfile: &str) -> (String, Vec<String>) {
    (
        "bun".to_string(),
        vec![
            "build".into(),
            entry.into(),
            "--compile".into(),
            format!("--target={}", arch.bun_target()),
            "--outfile".into(),
            outfile.into(),
        ],
    )
}

#[allow(dead_code)]
/// Where cargo writes the release cdylib: `target/<triple>/release/lib<crate>.so`.
/// `crate_lib_name` is the Cargo `[lib] name` (cargo already maps hyphens to
/// underscores in the produced filename).
pub fn cdylib_output_path(project: &Path, arch: Arch, crate_lib_name: &str) -> PathBuf {
    project
        .join("target")
        .join(arch.rust_triple())
        .join("release")
        .join(format!("lib{}.so", crate_lib_name))
}

#[allow(dead_code)]
/// Where cargo writes the release binary: `target/<triple>/release/<bin>`.
pub fn bin_output_path(project: &Path, arch: Arch, bin: &str) -> PathBuf {
    project
        .join("target")
        .join(arch.rust_triple())
        .join("release")
        .join(bin)
}

#[allow(dead_code)]
/// Per-arch staging dir the `build` step writes finished artifacts into and
/// `package` reads from: `target/node-app/<deb_arch>/`.
pub fn staged_artifact_dir(project: &Path, arch: Arch) -> PathBuf {
    project.join("target").join("node-app").join(arch.deb_arch())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn arch_mappings_are_stable() {
        assert_eq!(Arch::Arm64.rust_triple(), "aarch64-unknown-linux-gnu");
        assert_eq!(Arch::Amd64.rust_triple(), "x86_64-unknown-linux-gnu");
        assert_eq!(Arch::Arm64.bun_target(), "bun-linux-arm64");
        assert_eq!(Arch::Amd64.bun_target(), "bun-linux-x64");
        assert_eq!(Arch::Arm64.deb_arch(), "arm64");
        assert_eq!(Arch::Amd64.deb_arch(), "amd64");
    }

    #[test]
    fn from_deb_arch_parses_and_rejects() {
        assert_eq!(Arch::from_deb_arch("arm64").unwrap(), Arch::Arm64);
        assert_eq!(Arch::from_deb_arch("amd64").unwrap(), Arch::Amd64);
        let err = Arch::from_deb_arch("all").unwrap_err().to_string();
        assert!(err.contains("arm64 or amd64"), "got: {err}");
    }

    #[test]
    fn cargo_commands_pick_cross_and_target() {
        let (prog, args) = cargo_cdylib_command(Arch::Arm64, true);
        assert_eq!(prog, "cross");
        assert_eq!(
            args,
            vec!["build", "--release", "--target", "aarch64-unknown-linux-gnu"]
        );

        let (prog, args) = cargo_bin_command(Arch::Amd64, false, "node-app-foo");
        assert_eq!(prog, "cargo");
        assert_eq!(
            args,
            vec![
                "build",
                "--release",
                "--target",
                "x86_64-unknown-linux-gnu",
                "--bin",
                "node-app-foo"
            ]
        );
    }

    #[test]
    fn bun_compile_command_targets_arch() {
        let (prog, args) =
            bun_compile_command(Arch::Arm64, "src/index.ts", "target/node-app/arm64/node-app-foo");
        assert_eq!(prog, "bun");
        assert_eq!(
            args,
            vec![
                "build",
                "src/index.ts",
                "--compile",
                "--target=bun-linux-arm64",
                "--outfile",
                "target/node-app/arm64/node-app-foo"
            ]
        );
    }

    #[test]
    fn artifact_paths_follow_cargo_layout() {
        let p = Path::new("/proj");
        assert_eq!(
            cdylib_output_path(p, Arch::Arm64, "node_app_foo"),
            Path::new("/proj/target/aarch64-unknown-linux-gnu/release/libnode_app_foo.so")
        );
        assert_eq!(
            bin_output_path(p, Arch::Amd64, "node-app-foo"),
            Path::new("/proj/target/x86_64-unknown-linux-gnu/release/node-app-foo")
        );
        assert_eq!(
            staged_artifact_dir(p, Arch::Arm64),
            Path::new("/proj/target/node-app/arm64")
        );
    }
}