boltffi_cli 0.23.0

CLI for BoltFFI - generate Swift, Kotlin, and WASM bindings from Rust
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::thread;

use crate::android::AndroidToolchain;
use crate::config::Config;
use crate::error::{CliError, Result};
use crate::target::{Platform, RustTarget};

pub type OutputCallback = Box<dyn Fn(&str) + Send>;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CargoBuildProfile {
    Debug,
    Release,
    Named(String),
}

impl CargoBuildProfile {
    pub fn resolve(default_release: bool, cargo_args: &[String]) -> Self {
        let default_profile = if default_release {
            Self::Release
        } else {
            Self::Debug
        };

        let mut skip_next_argument = false;

        cargo_args.iter().enumerate().fold(
            default_profile,
            |resolved_profile, (index, argument)| {
                if skip_next_argument {
                    skip_next_argument = false;
                    return resolved_profile;
                }

                match argument.as_str() {
                    "--release" => Self::Release,
                    "--profile" => {
                        skip_next_argument = true;
                        cargo_args
                            .get(index + 1)
                            .map(|profile_name| Self::from_profile_name(profile_name))
                            .unwrap_or(resolved_profile)
                    }
                    _ => argument
                        .strip_prefix("--profile=")
                        .filter(|profile_name| !profile_name.is_empty())
                        .map(Self::from_profile_name)
                        .unwrap_or(resolved_profile),
                }
            },
        )
    }

    pub fn output_directory_name(&self) -> &str {
        match self {
            Self::Debug => "debug",
            Self::Release => "release",
            Self::Named(profile_name) => profile_name,
        }
    }

    pub fn is_release_like(&self) -> bool {
        !matches!(self, Self::Debug)
    }

    fn from_profile_name(profile_name: &str) -> Self {
        match profile_name {
            "debug" | "dev" => Self::Debug,
            "release" => Self::Release,
            _ => Self::Named(profile_name.to_string()),
        }
    }
}

pub fn resolve_build_profile(default_release: bool, cargo_args: &[String]) -> CargoBuildProfile {
    CargoBuildProfile::resolve(default_release, cargo_args)
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct CargoBuildCommandArgs {
    toolchain_selector: Option<String>,
    command_args: Vec<String>,
}

impl CargoBuildCommandArgs {
    fn from_passthrough_args(cargo_args: &[String]) -> Self {
        let toolchain_selector_index = cargo_args
            .iter()
            .position(|argument| is_toolchain_selector(argument));

        let (toolchain_selector, command_args) = toolchain_selector_index
            .map(|index| {
                let toolchain_selector = cargo_args.get(index).cloned();
                let command_args = cargo_args
                    .iter()
                    .take(index)
                    .chain(cargo_args.iter().skip(index + 1))
                    .cloned()
                    .collect();
                (toolchain_selector, command_args)
            })
            .unwrap_or_else(|| (None, cargo_args.to_vec()));

        Self {
            toolchain_selector,
            command_args,
        }
    }
}

fn is_toolchain_selector(argument: &str) -> bool {
    argument.starts_with('+') && argument.len() > 1
}

#[derive(Default)]
pub struct BuildOptions {
    pub release: bool,
    pub package: Option<String>,
    pub cargo_args: Vec<String>,
    pub on_output: Option<OutputCallback>,
}

pub struct Builder<'a> {
    config: &'a Config,
    options: BuildOptions,
}

pub struct BuildResult {
    pub triple: String,
    pub success: bool,
}

impl<'a> Builder<'a> {
    pub fn new(config: &'a Config, options: BuildOptions) -> Self {
        Self { config, options }
    }

    pub fn build_targets(&self, targets: &[RustTarget]) -> Result<Vec<BuildResult>> {
        let android_toolchain = targets
            .iter()
            .any(|target| target.platform() == Platform::Android)
            .then(|| {
                AndroidToolchain::discover(
                    self.config.android_min_sdk(),
                    self.config.android_ndk_version(),
                )
            })
            .transpose()?;

        targets
            .iter()
            .map(|target| self.build_single_target(target, android_toolchain.as_ref()))
            .collect()
    }

    pub fn build_android(&self, targets: &[RustTarget]) -> Result<Vec<BuildResult>> {
        self.build_targets(targets)
    }

    pub fn build_wasm_with_triple(&self, triple: &str) -> Result<Vec<BuildResult>> {
        let command_args = self.cargo_build_command_args();
        let mut command = Command::new("cargo");
        self.apply_cargo_build_prefix(&mut command, &command_args);
        command.arg("--target").arg(triple);

        self.apply_common_build_args(&mut command);
        command.args(&command_args.command_args);

        let success = run_command_streaming(&mut command, self.options.on_output.as_ref());

        Ok(vec![BuildResult {
            triple: triple.to_string(),
            success,
        }])
    }

    fn build_single_target(
        &self,
        target: &RustTarget,
        android_toolchain: Option<&AndroidToolchain>,
    ) -> Result<BuildResult> {
        let command_args = self.cargo_build_command_args();
        let mut cmd = Command::new("cargo");
        self.apply_cargo_build_prefix(&mut cmd, &command_args);
        cmd.arg("--target").arg(target.triple());

        self.apply_common_build_args(&mut cmd);
        cmd.args(&command_args.command_args);

        if target.platform() == Platform::Android {
            android_toolchain
                .ok_or(CliError::AndroidNdkNotFound)
                .and_then(|toolchain| toolchain.configure_cargo_for_target(&mut cmd, target))?;
        }

        let success = run_command_streaming(&mut cmd, self.options.on_output.as_ref());

        Ok(BuildResult {
            triple: target.triple().to_string(),
            success,
        })
    }

    fn package_name(&self) -> &str {
        self.options
            .package
            .as_deref()
            .unwrap_or(self.config.library_name())
    }

    fn apply_common_build_args(&self, command: &mut Command) {
        if self.options.release {
            command.arg("--release");
        }

        command.arg("-p").arg(self.package_name());
    }

    fn cargo_build_command_args(&self) -> CargoBuildCommandArgs {
        CargoBuildCommandArgs::from_passthrough_args(&self.options.cargo_args)
    }

    fn apply_cargo_build_prefix(
        &self,
        command: &mut Command,
        command_args: &CargoBuildCommandArgs,
    ) {
        if let Some(toolchain_selector) = command_args.toolchain_selector.as_deref() {
            command.arg(toolchain_selector);
        }

        command.arg("build");
    }
}

pub(crate) fn run_command_streaming(cmd: &mut Command, on_output: Option<&OutputCallback>) -> bool {
    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());

    let mut child = match cmd.spawn() {
        Ok(c) => c,
        Err(_) => return false,
    };

    let stdout = child.stdout.take();
    let stderr = child.stderr.take();

    let (tx, rx) = mpsc::channel();
    let stdout_tx = tx.clone();
    let stderr_tx = tx.clone();

    let stdout_handle = stdout.map(|out| {
        thread::spawn(move || {
            for line in BufReader::new(out)
                .lines()
                .map_while(std::result::Result::ok)
            {
                let _ = stdout_tx.send(line);
            }
        })
    });

    let stderr_handle = stderr.map(|err| {
        thread::spawn(move || {
            for line in BufReader::new(err)
                .lines()
                .map_while(std::result::Result::ok)
            {
                let _ = stderr_tx.send(line);
            }
        })
    });

    drop(tx);

    for line in rx {
        if let Some(cb) = on_output {
            cb(&line);
        }
    }

    if let Some(h) = stdout_handle {
        let _ = h.join();
    }
    if let Some(h) = stderr_handle {
        let _ = h.join();
    }

    child.wait().map(|s| s.success()).unwrap_or(false)
}
pub fn count_successful(results: &[BuildResult]) -> usize {
    results.iter().filter(|r| r.success).count()
}

pub fn all_successful(results: &[BuildResult]) -> bool {
    results.iter().all(|r| r.success)
}

pub fn failed_targets(results: &[BuildResult]) -> Vec<String> {
    results
        .iter()
        .filter(|r| !r.success)
        .map(|r| r.triple.clone())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::{
        CargoBuildCommandArgs, CargoBuildProfile, resolve_build_profile, run_command_streaming,
    };
    use std::process::Command;
    use std::sync::mpsc;
    use std::time::Duration;

    #[test]
    fn resolves_release_profile_from_passthrough_args() {
        let cargo_args = vec!["--locked".to_string(), "--release".to_string()];

        assert_eq!(
            resolve_build_profile(false, &cargo_args),
            CargoBuildProfile::Release
        );
    }

    #[test]
    fn resolves_named_profile_from_passthrough_args() {
        let cargo_args = vec!["--profile".to_string(), "mobile-release".to_string()];

        assert_eq!(
            resolve_build_profile(false, &cargo_args),
            CargoBuildProfile::Named("mobile-release".to_string())
        );
    }

    #[test]
    fn resolves_debug_profile_from_dev_profile_name() {
        let cargo_args = vec!["--profile=dev".to_string()];

        assert_eq!(
            resolve_build_profile(true, &cargo_args),
            CargoBuildProfile::Debug
        );
    }

    #[test]
    fn extracts_toolchain_selector_from_passthrough_args() {
        let cargo_args = vec![
            "--locked".to_string(),
            "+nightly".to_string(),
            "--features".to_string(),
            "mobile".to_string(),
        ];

        let command_args = CargoBuildCommandArgs::from_passthrough_args(&cargo_args);

        assert_eq!(
            command_args.toolchain_selector,
            Some("+nightly".to_string())
        );
        assert_eq!(
            command_args.command_args,
            vec![
                "--locked".to_string(),
                "--features".to_string(),
                "mobile".to_string()
            ]
        );
    }

    #[test]
    fn streaming_command_returns_after_child_exits() {
        let (tx, rx) = mpsc::channel();

        std::thread::spawn(move || {
            let mut command = if cfg!(windows) {
                let mut cmd = Command::new("cmd");
                cmd.args(["/C", "echo", "ok"]);
                cmd
            } else {
                let mut cmd = Command::new("sh");
                cmd.args(["-c", "printf ok"]);
                cmd
            };

            let result = run_command_streaming(&mut command, None);
            let _ = tx.send(result);
        });

        let result = rx
            .recv_timeout(Duration::from_secs(5))
            .expect("run_command_streaming should finish once the child exits");
        assert!(result);
    }
}