boltffi_cli 0.27.2

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

use askama::Template;

use crate::cli::{CliError, Result};
use crate::config::{Config, SpmLayout};

use super::names::AppleNames;

pub struct SpmPackageGenerator<'a> {
    config: &'a Config,
    names: AppleNames,
    distribution: SpmPackageDistribution,
    layout: SpmLayout,
}

enum SpmPackageDistribution {
    Local,
    Remote(RemoteSpmPackage),
}

struct RemoteSpmPackage {
    checksum: String,
    version: String,
}

struct ApplePackageManifest {
    tools_version: String,
    package_name: String,
    product_target_name: String,
    binary_target_name: String,
    module_name: String,
    wrapper_sources: String,
    xcframework_name: String,
    platform_declarations: Vec<String>,
    has_wrapper_target: bool,
}

#[derive(Template)]
#[template(path = "ApplePackageLocal.swift", escape = "none")]
struct AppleLocalPackageTemplate<'a> {
    manifest: &'a ApplePackageManifest,
    xcframework_path: &'a str,
}

#[derive(Template)]
#[template(path = "ApplePackageRemote.swift", escape = "none")]
struct AppleRemotePackageTemplate<'a> {
    manifest: &'a ApplePackageManifest,
    repo_url: &'a str,
    version: &'a str,
    checksum: &'a str,
}

impl<'a> SpmPackageGenerator<'a> {
    pub fn new_local(config: &'a Config, layout: SpmLayout) -> Self {
        Self {
            config,
            names: AppleNames::from_config(config),
            distribution: SpmPackageDistribution::Local,
            layout,
        }
    }

    pub fn new_remote(
        config: &'a Config,
        checksum: String,
        version: String,
        layout: SpmLayout,
    ) -> Self {
        Self {
            config,
            names: AppleNames::from_config(config),
            distribution: SpmPackageDistribution::Remote(RemoteSpmPackage { checksum, version }),
            layout,
        }
    }

    pub fn generate(&self) -> Result<PathBuf> {
        let output_path = self.config.apple_spm_output().join("Package.swift");

        let content = self.render_package()?;

        let spm_output = self.config.apple_spm_output();
        std::fs::create_dir_all(&spm_output).map_err(|source| CliError::CreateDirectoryFailed {
            path: spm_output.clone(),
            source,
        })?;

        std::fs::write(&output_path, content).map_err(|source| CliError::WriteFailed {
            path: output_path.clone(),
            source,
        })?;

        Ok(output_path)
    }

    fn render_package(&self) -> Result<String> {
        match &self.distribution {
            SpmPackageDistribution::Local => self.render_local_package(),
            SpmPackageDistribution::Remote(remote_package) => {
                self.render_remote_package_with(remote_package)
            }
        }
    }

    fn render_local_package(&self) -> Result<String> {
        let manifest = self.package_manifest();
        let xcframework_path = self.local_xcframework_path();

        render_apple_package_template(
            AppleLocalPackageTemplate {
                manifest: &manifest,
                xcframework_path: &xcframework_path,
            },
            "local",
        )
    }

    fn render_remote_package_with(&self, remote_package: &RemoteSpmPackage) -> Result<String> {
        let manifest = self.package_manifest();
        let repo_url = self
            .config
            .apple_spm_repo_url()
            .unwrap_or("https://github.com/user/repo");

        render_apple_package_template(
            AppleRemotePackageTemplate {
                manifest: &manifest,
                repo_url,
                version: &remote_package.version,
                checksum: &remote_package.checksum,
            },
            "remote",
        )
    }

    fn package_manifest(&self) -> ApplePackageManifest {
        let layout = self.layout;
        let package_name = self.package_name_for_layout(layout);
        let module_name = self.names.swift_module_name().to_string();
        let tools_version = self
            .config
            .apple_swift_tools_version()
            .unwrap_or("5.9")
            .to_string();
        let wrapper_sources = self.wrapper_sources_path(layout);
        let binary_target_name = self.names.ffi_module_name().to_string();
        let product_target_name = if matches!(layout, SpmLayout::Split) {
            binary_target_name.clone()
        } else {
            module_name.clone()
        };
        let platform_declarations = self.platform_declarations();

        ApplePackageManifest {
            tools_version,
            package_name,
            product_target_name,
            binary_target_name,
            module_name,
            wrapper_sources,
            xcframework_name: self.names.xcframework_name().to_string(),
            platform_declarations,
            has_wrapper_target: !matches!(layout, SpmLayout::Split),
        }
    }

    fn ios_version_for_spm(&self) -> String {
        let deployment_target = self.config.apple_deployment_target();

        deployment_target
            .split('.')
            .next()
            .map(|major| format!("v{}", major))
            .unwrap_or_else(|| "v16".to_string())
    }

    fn platform_declarations(&self) -> Vec<String> {
        let mut platforms = Vec::new();

        if self.supports_ios_platform() {
            platforms.push(format!(".iOS(.{})", self.ios_version_for_spm()));
        }

        if self.supports_macos_platform() {
            platforms.push(".macOS(.v13)".to_string());
        }

        platforms
    }

    fn supports_ios_platform(&self) -> bool {
        !self.config.apple_ios_targets().is_empty()
            || !self.config.apple_simulator_targets().is_empty()
    }

    fn supports_macos_platform(&self) -> bool {
        self.config.apple_include_macos() && !self.config.apple_macos_targets().is_empty()
    }

    fn wrapper_sources_path(&self, layout: SpmLayout) -> String {
        match layout {
            SpmLayout::Bundled => self
                .config
                .apple_spm_wrapper_sources()
                .map(|p| p.to_string_lossy().to_string())
                .unwrap_or_else(|| "Sources".to_string()),
            SpmLayout::Split | SpmLayout::FfiOnly => "Sources".to_string(),
        }
    }

    fn local_xcframework_path(&self) -> String {
        let package_root = self.config.apple_spm_output();
        let xcframework_path = self
            .config
            .apple_xcframework_output()
            .join(format!("{}.xcframework", self.names.xcframework_name()));
        let rel = relative_path(&package_root, &xcframework_path);
        rel.to_string_lossy().to_string()
    }

    fn package_name_for_layout(&self, layout: SpmLayout) -> String {
        self.config
            .apple_spm_package_name()
            .map(|name| name.to_string())
            .unwrap_or_else(|| match layout {
                SpmLayout::Split => self.names.ffi_module_name().to_string(),
                SpmLayout::Bundled | SpmLayout::FfiOnly => {
                    self.names.swift_module_name().to_string()
                }
            })
    }
}

fn render_apple_package_template(template: impl Template, distribution: &str) -> Result<String> {
    template.render().map_err(|source| CliError::CommandFailed {
        command: format!("render {distribution} Apple Package.swift template: {source}"),
        status: None,
    })
}

fn relative_path(from_dir: &std::path::Path, to_path: &std::path::Path) -> PathBuf {
    if from_dir == std::path::Path::new(".") || from_dir == std::path::Path::new("") {
        return to_path.to_path_buf();
    }

    let from_components = from_dir.components().collect::<Vec<_>>();
    let to_components = to_path.components().collect::<Vec<_>>();

    let common_len = from_components
        .iter()
        .zip(to_components.iter())
        .take_while(|(a, b)| a == b)
        .count();

    let parent_count = from_components.len().saturating_sub(common_len);
    let parent_prefix = (0..parent_count).map(|_| std::path::Component::ParentDir);
    let suffix = to_components.iter().skip(common_len).copied();

    parent_prefix.chain(suffix).collect()
}

#[cfg(test)]
mod tests {
    use crate::config::{PackageConfig, SpmDistribution, TargetsConfig};
    use crate::target::Architecture;

    use super::*;

    struct AppleSpmConfigBuilder {
        config: Config,
    }

    impl AppleSpmConfigBuilder {
        fn new() -> Self {
            Self {
                config: Config {
                    experimental: Vec::new(),
                    cargo: Default::default(),
                    package: PackageConfig {
                        name: "mylib".to_string(),
                        crate_name: None,
                        version: None,
                        description: None,
                        license: None,
                        repository: None,
                    },
                    targets: TargetsConfig::default(),
                },
            }
        }

        fn with_macos_enabled(mut self) -> Self {
            self.config.targets.apple.include_macos = true;
            self
        }

        fn without_macos_slices(mut self) -> Self {
            self.config.targets.apple.macos_architectures = Some(Vec::new());
            self
        }

        fn without_ios_slices(mut self) -> Self {
            self.config.targets.apple.ios_architectures = Some(Vec::new());
            self.config.targets.apple.simulator_architectures = Some(Vec::new());
            self
        }

        fn with_macos_arm64_slice(mut self) -> Self {
            self.config.targets.apple.macos_architectures = Some(vec![Architecture::Arm64]);
            self
        }

        fn with_simulator_arm64_slice(mut self) -> Self {
            self.config.targets.apple.simulator_architectures = Some(vec![Architecture::Arm64]);
            self
        }

        fn with_remote_spm(mut self, repo_url: &str) -> Self {
            self.config.targets.apple.spm.distribution = SpmDistribution::Remote;
            self.config.targets.apple.spm.repo_url = Some(repo_url.to_string());
            self
        }

        fn build(self) -> Config {
            self.config.validate().expect("config validation failed");
            self.config
        }
    }

    #[test]
    fn spm_omits_macos_platform_when_enabled_without_macos_slices() {
        let config = AppleSpmConfigBuilder::new()
            .with_macos_enabled()
            .without_macos_slices()
            .build();

        let package = SpmPackageGenerator::new_local(&config, SpmLayout::FfiOnly)
            .render_package()
            .expect("render local package");

        assert!(package.contains(".iOS(.v16)"));
        assert!(!package.contains(".macOS(.v13)"));
    }

    #[test]
    fn spm_omits_ios_platform_for_macos_only_packaging() {
        let config = AppleSpmConfigBuilder::new()
            .with_macos_enabled()
            .without_ios_slices()
            .with_macos_arm64_slice()
            .build();

        let package = SpmPackageGenerator::new_local(&config, SpmLayout::FfiOnly)
            .render_package()
            .expect("render local package");

        assert!(!package.contains(".iOS(.v16)"));
        assert!(package.contains(".macOS(.v13)"));
    }

    #[test]
    fn spm_keeps_ios_platform_for_simulator_only_packaging() {
        let config = AppleSpmConfigBuilder::new()
            .without_ios_slices()
            .with_simulator_arm64_slice()
            .build();

        let package = SpmPackageGenerator::new_local(&config, SpmLayout::FfiOnly)
            .render_package()
            .expect("render local package");

        assert!(package.contains(".iOS(.v16)"));
        assert!(!package.contains(".macOS(.v13)"));
    }

    #[test]
    fn spm_renders_remote_package_from_template() {
        let config = AppleSpmConfigBuilder::new()
            .with_remote_spm("https://example.com/releases")
            .build();

        let package = SpmPackageGenerator::new_remote(
            &config,
            "abc123".to_string(),
            "1.2.3".to_string(),
            SpmLayout::Bundled,
        )
        .render_package()
        .expect("render remote package");

        assert!(package.contains("let releaseTag = \"1.2.3\""));
        assert!(package.contains("let releaseChecksum = \"abc123\""));
        assert!(package.contains(
            "url: \"https://example.com/releases/releases/download/\\(releaseTag)/Mylib.xcframework.zip\""
        ));
        assert!(package.contains("checksum: releaseChecksum"));
        assert!(package.contains(".target("));
        assert!(package.contains("path: \"Sources\""));
    }
}