boltffi_cli 0.27.5

CLI for BoltFFI - generate Swift, Kotlin, and WASM bindings from Rust
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Command;

use crate::cli::{CliError, Result};
use crate::target::{Architecture, RustTarget};

#[derive(Debug, thiserror::Error)]
pub enum AndroidToolchainError {
    #[error("android ndk not found (set ANDROID_NDK_HOME or ANDROID_HOME/ANDROID_SDK_ROOT)")]
    NdkNotFound,

    #[error("invalid android ndk at {path}")]
    NdkInvalid { path: PathBuf },

    #[error("android ndk toolchain not found at {path}")]
    ToolchainNotFound { path: PathBuf },
}

#[derive(Debug, Clone)]
pub struct AndroidNdk {
    root: PathBuf,
    bin_dir: PathBuf,
    sysroot_dir: PathBuf,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AndroidAbi {
    Arm64V8a,
    ArmeabiV7a,
    X86_64,
    X86,
}

#[derive(Debug, Clone)]
pub struct AndroidToolchain {
    ndk: AndroidNdk,
    min_sdk: u32,
}

impl AndroidToolchain {
    pub fn discover(min_sdk: u32, ndk_version_hint: Option<&str>) -> Result<Self> {
        Ok(Self {
            ndk: AndroidNdk::discover(ndk_version_hint)?,
            min_sdk,
        })
    }

    pub fn configure_cargo_for_target(
        &self,
        cargo: &mut Command,
        target: &RustTarget,
    ) -> Result<()> {
        let abi = AndroidAbi::from_architecture(target.architecture()).ok_or_else(|| {
            CliError::CommandFailed {
                command: format!("unsupported android target {}", target.triple()),
                status: None,
            }
        })?;

        let linker = self.ndk.clang_for_abi(abi, self.min_sdk);
        let ar = self.ndk.llvm_ar();
        let triple_env_upper = cargo_env_triple_upper(target.triple());
        let triple_env_lower = cargo_env_triple_lower(target.triple());

        cargo.env(format!("CARGO_TARGET_{}_LINKER", triple_env_upper), &linker);
        cargo.env(format!("CARGO_TARGET_{}_AR", triple_env_upper), &ar);
        cargo.env(format!("CC_{}", triple_env_lower), &linker);
        cargo.env(format!("AR_{}", triple_env_lower), &ar);

        let sysroot = self.ndk.sysroot();
        let include_target = android_sysroot_target(target.triple());

        let bindgen_key = format!("BINDGEN_EXTRA_CLANG_ARGS_{}", triple_env_lower);
        let bindgen_args = format!(
            "--sysroot={} -isystem {}/usr/include -isystem {}/usr/include/{}",
            sysroot.display(),
            sysroot.display(),
            sysroot.display(),
            include_target,
        );
        let bindgen_clang_args = bindgen_args.replace('\\', "/");

        let merged_args = match std::env::var(&bindgen_key) {
            Ok(existing) if !existing.trim().is_empty() => {
                format!("{bindgen_clang_args} {existing}")
            }
            _ => bindgen_clang_args,
        };

        cargo.env(bindgen_key, merged_args);

        Ok(())
    }

    pub fn clang_for_target(&self, target: &RustTarget) -> Result<PathBuf> {
        let abi = AndroidAbi::from_architecture(target.architecture()).ok_or_else(|| {
            CliError::CommandFailed {
                command: format!("unsupported android target {}", target.triple()),
                status: None,
            }
        })?;
        Ok(self.ndk.clang_for_abi(abi, self.min_sdk))
    }

    #[allow(dead_code)]
    pub fn llvm_ar(&self) -> PathBuf {
        self.ndk.llvm_ar()
    }
}

impl AndroidNdk {
    pub fn discover(ndk_version_hint: Option<&str>) -> Result<Self> {
        let root = resolve_ndk_root(ndk_version_hint).ok_or(AndroidToolchainError::NdkNotFound)?;
        let prebuilt_dir = resolve_prebuilt_dir(&root)?;
        let bin_dir = prebuilt_dir.join("bin");
        let sysroot_dir = prebuilt_dir.join("sysroot");

        Ok(Self {
            root,
            sysroot_dir,
            bin_dir,
        })
    }

    pub fn root(&self) -> &Path {
        &self.root
    }

    pub fn sysroot(&self) -> &Path {
        &self.sysroot_dir
    }

    pub fn clang_for_abi(&self, abi: AndroidAbi, min_sdk: u32) -> PathBuf {
        let name = format!("{}{}-clang", abi.clang_prefix(), min_sdk);
        self.bin_dir.join(with_platform_ext(&name, "cmd"))
    }

    pub fn llvm_ar(&self) -> PathBuf {
        self.bin_dir.join(with_platform_ext("llvm-ar", "exe"))
    }
}

impl AndroidAbi {
    #[allow(dead_code)]
    pub fn directory_name(&self) -> &'static str {
        match self {
            AndroidAbi::Arm64V8a => "arm64-v8a",
            AndroidAbi::ArmeabiV7a => "armeabi-v7a",
            AndroidAbi::X86_64 => "x86_64",
            AndroidAbi::X86 => "x86",
        }
    }

    fn clang_prefix(&self) -> &'static str {
        match self {
            AndroidAbi::Arm64V8a => "aarch64-linux-android",
            AndroidAbi::ArmeabiV7a => "armv7a-linux-androideabi",
            AndroidAbi::X86_64 => "x86_64-linux-android",
            AndroidAbi::X86 => "i686-linux-android",
        }
    }

    fn from_architecture(architecture: Architecture) -> Option<Self> {
        match architecture {
            Architecture::Arm64 => Some(AndroidAbi::Arm64V8a),
            Architecture::Armv7 => Some(AndroidAbi::ArmeabiV7a),
            Architecture::X86_64 => Some(AndroidAbi::X86_64),
            Architecture::X86 => Some(AndroidAbi::X86),
            Architecture::Wasm32 => None,
        }
    }
}

fn cargo_env_triple_upper(triple: &str) -> String {
    triple.replace('-', "_").to_uppercase()
}

fn cargo_env_triple_lower(triple: &str) -> String {
    triple.replace('-', "_").to_lowercase()
}

fn with_platform_ext(name: &str, windows_ext: &str) -> String {
    if cfg!(windows) {
        format!("{}.{}", name, windows_ext)
    } else {
        name.to_string()
    }
}

fn resolve_ndk_root(ndk_version_hint: Option<&str>) -> Option<PathBuf> {
    resolve_ndk_root_from_env_var("ANDROID_NDK_HOME", ndk_version_hint)
        .or_else(|| resolve_ndk_root_from_android_home(ndk_version_hint))
}

fn resolve_ndk_root_from_env_var(env_var: &str, ndk_version_hint: Option<&str>) -> Option<PathBuf> {
    std::env::var_os(env_var)
        .map(PathBuf::from)
        .and_then(|path| resolve_ndk_root_from_candidate(&path, ndk_version_hint))
}

fn resolve_ndk_root_from_android_home(ndk_version_hint: Option<&str>) -> Option<PathBuf> {
    let android_home = std::env::var_os("ANDROID_HOME")
        .or_else(|| std::env::var_os("ANDROID_SDK_ROOT"))
        .map(PathBuf::from)?;

    let ndk_bundle = android_home.join("ndk-bundle");
    resolve_ndk_root_from_candidate(&ndk_bundle, ndk_version_hint).or_else(|| {
        let ndk_dir = android_home.join("ndk");
        resolve_ndk_root_from_candidate(&ndk_dir, ndk_version_hint)
    })
}

fn resolve_ndk_root_from_candidate(
    candidate: &Path,
    ndk_version_hint: Option<&str>,
) -> Option<PathBuf> {
    is_ndk_root(candidate)
        .then(|| candidate.to_path_buf())
        .or_else(|| select_ndk_version_dir(candidate, ndk_version_hint))
}

fn is_ndk_root(path: &Path) -> bool {
    path.join("toolchains")
        .join("llvm")
        .join("prebuilt")
        .exists()
}

fn select_ndk_version_dir(parent: &Path, ndk_version_hint: Option<&str>) -> Option<PathBuf> {
    let entries = std::fs::read_dir(parent).ok()?;

    let versioned_dirs: Vec<(PathBuf, NdkVersion, String)> = entries
        .filter_map(|entry| entry.ok().map(|entry| entry.path()))
        .filter(|path| path.is_dir())
        .filter(|path| is_ndk_root(path))
        .filter_map(|path| {
            let dir_name = path
                .file_name()
                .and_then(OsStr::to_str)
                .map(|name| name.to_string())?;
            Some((path, NdkVersion::parse(&dir_name)?, dir_name))
        })
        .collect();

    ndk_version_hint
        .and_then(|hint| {
            NdkVersion::parse(hint).map(|hint_version| (hint_version, hint.to_string()))
        })
        .and_then(|(hint_version, hint_string)| {
            versioned_dirs
                .iter()
                .find(|(_, version, _)| *version == hint_version)
                .map(|(path, _, _)| path.clone())
                .or_else(|| {
                    versioned_dirs
                        .iter()
                        .find(|(_, _, name)| name == &hint_string)
                        .map(|(path, _, _)| path.clone())
                })
        })
        .or_else(|| {
            versioned_dirs
                .into_iter()
                .max_by_key(|(_, version, _)| version.clone())
                .map(|(path, _, _)| path)
        })
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct NdkVersion {
    parts: Vec<u32>,
}

impl NdkVersion {
    fn parse(input: &str) -> Option<Self> {
        let parts: Vec<u32> = input
            .split('.')
            .filter_map(|part| part.parse::<u32>().ok())
            .collect();
        (!parts.is_empty()).then_some(Self { parts })
    }
}

fn resolve_prebuilt_dir(ndk_root: &Path) -> Result<PathBuf> {
    let prebuilt_dir = ndk_root.join("toolchains").join("llvm").join("prebuilt");
    if !prebuilt_dir.exists() {
        return Err(AndroidToolchainError::NdkInvalid {
            path: ndk_root.to_path_buf(),
        }
        .into());
    }

    let preferred = preferred_prebuilt_tags();
    let matching = preferred
        .iter()
        .map(|tag| prebuilt_dir.join(tag))
        .find(|path| path.join("bin").exists())
        .or_else(|| {
            std::fs::read_dir(&prebuilt_dir)
                .ok()
                .into_iter()
                .flat_map(|entries| {
                    entries.filter_map(|entry| entry.ok().map(|entry| entry.path()))
                })
                .find(|path| path.join("bin").exists())
        })
        .ok_or_else(|| {
            crate::cli::CliError::from(AndroidToolchainError::ToolchainNotFound {
                path: ndk_root.to_path_buf(),
            })
        })?;

    Ok(matching)
}

fn preferred_prebuilt_tags() -> Vec<&'static str> {
    match (std::env::consts::OS, std::env::consts::ARCH) {
        ("macos", "aarch64") => vec!["darwin-arm64", "darwin-x86_64"],
        ("macos", "x86_64") => vec!["darwin-x86_64"],
        ("linux", "x86_64") => vec!["linux-x86_64"],
        ("linux", "aarch64") => vec!["linux-aarch64", "linux-x86_64"],
        ("windows", "x86_64") => vec!["windows-x86_64"],
        _ => Vec::new(),
    }
}

fn android_sysroot_target(triple: &str) -> &str {
    match triple {
        "armv7-linux-androideabi" => "arm-linux-androideabi",
        _ => triple,
    }
}