boltffi_cli 0.27.3

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

use boltffi_bindgen::KotlinOptions;
use boltffi_bindgen::render::kmp::{
    KMP_SUPPORT_REPORT_FILE, KMPEmitter, KMPOptions, KmpSupportPolicy,
};
use boltffi_bindgen::render::kotlin::{
    FactoryStyle as BindgenFactoryStyle, KotlinApiStyle, KotlinDesktopLoader,
};

use boltffi_bindgen::target::Target;

use crate::cli::{CliError, Result};
use crate::commands::generate::generator::SourceCrate;
use crate::commands::generate::generator::{GenerateRequest, LanguageGenerator, ScanPointerWidth};
use crate::config::KotlinFactoryStyle as ConfigKotlinFactoryStyle;

const KMP_MANAGED_GENERATED_PATHS: &[&str] = &[
    "settings.gradle.kts",
    "build.gradle.kts",
    KMP_SUPPORT_REPORT_FILE,
    "src/commonMain/kotlin",
    "src/jvmMain/kotlin",
    "src/androidMain/kotlin",
    "src/jvmMain/c",
    "src/androidMain/c",
];

pub struct KMPGenerator;

impl KMPGenerator {
    pub fn generate_from_source_directory_with_desktop_fallback_library_name(
        config: &crate::config::Config,
        output_override: Option<PathBuf>,
        source_directory: &Path,
        crate_name: &str,
        desktop_fallback_library_name: Option<&str>,
    ) -> Result<()> {
        let request = GenerateRequest::new(
            config,
            output_override,
            SourceCrate::new(source_directory, crate_name),
        );

        Self::generate_with_desktop_fallback_library_name(&request, desktop_fallback_library_name)
    }

    fn kotlin_options(
        request: &GenerateRequest<'_>,
        module_name: &str,
        desktop_fallback_library_name: Option<&str>,
    ) -> KotlinOptions {
        let factory_style = match request.config().android_kotlin_factory_style() {
            ConfigKotlinFactoryStyle::Constructors => BindgenFactoryStyle::Constructors,
            ConfigKotlinFactoryStyle::CompanionMethods => BindgenFactoryStyle::CompanionMethods,
        };
        let desktop_fallback_library_name =
            desktop_fallback_library_name.unwrap_or_else(|| request.source_crate().crate_name());

        KotlinOptions {
            factory_style,
            api_style: KotlinApiStyle::TopLevel,
            module_object_name: Some(module_name.to_string()),
            library_name: Some(boltffi_bindgen::load_library_name(
                &request.config().resolved_android_kotlin_library_name(),
            )),
            desktop_jni_library_name: Some(boltffi_bindgen::library_name(
                &request
                    .config()
                    .resolved_android_kotlin_desktop_library_name(),
            )),
            desktop_fallback_library_name: Some(boltffi_bindgen::library_name(
                desktop_fallback_library_name,
            )),
            desktop_loader: KotlinDesktopLoader::Bundled,
        }
    }

    fn generate_with_desktop_fallback_library_name(
        request: &GenerateRequest<'_>,
        desktop_fallback_library_name: Option<&str>,
    ) -> Result<()> {
        if !request.config().is_kotlin_multiplatform_enabled() {
            return Err(CliError::CommandFailed {
                command: "targets.kotlin_multiplatform.enabled = false".to_string(),
                status: None,
            });
        }

        let output_directory = request
            .output_override()
            .map(ToOwned::to_owned)
            .unwrap_or_else(|| request.config().kotlin_multiplatform_output());
        request.ensure_output_directory(&output_directory)?;

        let lowered_crate = request.lowered_crate(ScanPointerWidth::Flexible)?;
        let module_name = request.config().kotlin_multiplatform_module_name();
        let support_policy = if request
            .config()
            .kotlin_multiplatform_preview_prune_unsupported()
        {
            eprintln!(
                "warning: KMP preview pruning is enabled; unsupported APIs will be omitted and recorded in {}",
                output_directory.join(KMP_SUPPORT_REPORT_FILE).display()
            );
            KmpSupportPolicy::PreviewPruneUnsupported
        } else {
            KmpSupportPolicy::Strict
        };
        let kmp_output = KMPEmitter::emit(
            &lowered_crate.ffi_contract,
            &lowered_crate.abi_contract,
            KMPOptions {
                package_name: request.config().kotlin_multiplatform_package(),
                module_name: module_name.clone(),
                min_sdk: request.config().android_min_sdk(),
                support_policy,
                kotlin_options: Self::kotlin_options(
                    request,
                    &module_name,
                    desktop_fallback_library_name,
                ),
            },
        )
        .map_err(|error| CliError::CommandFailed {
            command: format!("generate kmp: {error}"),
            status: None,
        })?;

        prepare_kmp_output_directory(request, &output_directory)?;

        kmp_output.files.iter().try_for_each(|output_file| {
            let output_path = output_directory.join(&output_file.relative_path);

            if let Some(parent_directory) = output_path.parent() {
                request.ensure_output_directory(parent_directory)?;
            }

            request.write_output(&output_path, &output_file.contents)
        })
    }
}

fn prepare_kmp_output_directory(
    request: &GenerateRequest<'_>,
    output_directory: &Path,
) -> Result<()> {
    request.ensure_output_directory(output_directory)?;
    remove_stale_kmp_generated_paths(output_directory)
}

pub(crate) fn remove_stale_kmp_generated_paths(output_directory: &Path) -> Result<()> {
    KMP_MANAGED_GENERATED_PATHS
        .iter()
        .map(|relative_path| output_directory.join(relative_path))
        .try_for_each(remove_stale_generated_path)
}

fn remove_stale_generated_path(path: PathBuf) -> Result<()> {
    let metadata = match fs::symlink_metadata(&path) {
        Ok(metadata) => metadata,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
        Err(source) => {
            return Err(CliError::ReadFailed { path, source });
        }
    };

    if metadata.is_dir() {
        fs::remove_dir_all(&path).map_err(|source| CliError::WriteFailed { path, source })
    } else {
        fs::remove_file(&path).map_err(|source| CliError::WriteFailed { path, source })
    }
}

impl LanguageGenerator for KMPGenerator {
    const TARGET: Target = Target::KotlinMultiplatform;

    fn generate(request: &GenerateRequest<'_>) -> Result<()> {
        Self::generate_with_desktop_fallback_library_name(request, None)
    }
}

#[cfg(test)]
mod tests {
    use super::remove_stale_kmp_generated_paths;
    use std::fs;
    use std::path::PathBuf;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn unique_temp_dir(prefix: &str) -> PathBuf {
        let unique_suffix = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time before unix epoch")
            .as_nanos();

        std::env::temp_dir().join(format!("{prefix}-{unique_suffix}"))
    }

    #[test]
    fn stale_kmp_generated_cleanup_preserves_packaged_native_outputs() {
        let output_directory = unique_temp_dir("boltffi-kmp-generated-cleanup-test");
        let stale_common = output_directory.join("src/commonMain/kotlin/com/old/Old.kt");
        let stale_jvm_c = output_directory.join("src/jvmMain/c/jni_glue.c");
        let native_resource = output_directory.join("src/jvmMain/resources/native/current/lib.so");
        let android_jnilib = output_directory.join("src/androidMain/jniLibs/arm64-v8a/libdemo.so");

        for path in [
            &stale_common,
            &stale_jvm_c,
            &native_resource,
            &android_jnilib,
        ] {
            fs::create_dir_all(path.parent().expect("test path has parent"))
                .expect("create test directory");
            fs::write(path, []).expect("write test file");
        }
        fs::write(output_directory.join("build.gradle.kts"), []).expect("write stale gradle file");

        remove_stale_kmp_generated_paths(&output_directory).expect("cleanup should succeed");

        assert!(!stale_common.exists());
        assert!(!stale_jvm_c.exists());
        assert!(!output_directory.join("build.gradle.kts").exists());
        assert!(native_resource.exists());
        assert!(android_jnilib.exists());

        fs::remove_dir_all(output_directory).expect("cleanup temp output");
    }
}