boltffi_cli 0.27.2

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

use serde::Serialize;

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

struct WasmLoaderEntrypoint {
    filename: &'static str,
    source: String,
}

impl WasmLoaderEntrypoint {
    fn browser(filename: &'static str, module_name: &str) -> Self {
        Self {
            filename,
            source: format!(
                r#"
                    import init from "./{module_name}.js";
                    export * from "./{module_name}.js";
                    export {{ default as init }} from "./{module_name}.js";
                    export const initialized = (async () => {{
                    const response = await fetch(new URL("./{module_name}_bg.wasm", import.meta.url));
                    await init(response);
                    }})();
                "#
            ),
        }
    }

    fn node(filename: &'static str, module_name: &str) -> Self {
        Self {
            filename,
            source: format!(
                r#"
                   export * from "./{module_name}_node.js";
                   export {{ default, initialized }} from "./{module_name}_node.js";
                "#
            ),
        }
    }

    fn write(self, output_directory: &Path) -> Result<()> {
        let output_path = output_directory.join(self.filename);
        std::fs::write(&output_path, self.source).map_err(|source| CliError::WriteFailed {
            path: output_path,
            source,
        })
    }
}

impl WasmNpmTarget {
    fn loader_entrypoint(&self, module_name: &str) -> WasmLoaderEntrypoint {
        match self {
            Self::Bundler => WasmLoaderEntrypoint::browser("bundler.js", module_name),
            Self::Web => WasmLoaderEntrypoint::browser("web.js", module_name),
            Self::Nodejs => WasmLoaderEntrypoint::node("node.js", module_name),
        }
    }
}

pub(crate) fn generate_wasm_loader_entrypoints(
    module_name: &str,
    enabled_targets: &[WasmNpmTarget],
    output_dir: &Path,
) -> Result<()> {
    enabled_targets
        .iter()
        .map(|target| target.loader_entrypoint(module_name))
        .try_for_each(|entrypoint| entrypoint.write(output_dir))
}

pub(crate) fn generate_wasm_package_json(
    config: &Config,
    module_name: &str,
    enabled_targets: &[WasmNpmTarget],
    output_dir: &Path,
) -> Result<PathBuf> {
    let package_name = config
        .wasm_npm_package_name()
        .ok_or_else(|| CliError::CommandFailed {
            command: "targets.wasm.npm.package_name is required for pack wasm".to_string(),
            status: None,
        })?;
    let package_version = config
        .wasm_npm_version()
        .unwrap_or_else(|| "0.1.0".to_string());

    let has_bundler = enabled_targets.contains(&WasmNpmTarget::Bundler);
    let has_web = enabled_targets.contains(&WasmNpmTarget::Web);
    let has_node = enabled_targets.contains(&WasmNpmTarget::Nodejs);
    let default_entry = if has_bundler {
        "./bundler.js"
    } else if has_web {
        "./web.js"
    } else {
        "./node.js"
    };

    let runtime_package = config.wasm_runtime_package();
    let runtime_version = config.wasm_runtime_version();
    let mut dependencies = BTreeMap::new();
    dependencies.insert(runtime_package, runtime_version);

    let package_json = WasmPackageJson {
        name: package_name.to_string(),
        version: package_version,
        package_type: "module".to_string(),
        exports: WasmPackageExports {
            root: WasmPackageEntry {
                types: format!("./{}.d.ts", module_name),
                browser: has_web.then(|| "./web.js".to_string()),
                node: has_node.then(|| "./node.js".to_string()),
                default: default_entry.to_string(),
            },
        },
        types: format!("./{}.d.ts", module_name),
        files: vec![
            format!("{}.js", module_name),
            format!("{}.d.ts", module_name),
            format!("{}_bg.wasm", module_name),
            "bundler.js".to_string(),
            "web.js".to_string(),
            "node.js".to_string(),
        ],
        dependencies,
        license: config.wasm_npm_license(),
        repository: config.wasm_npm_repository(),
    };

    let rendered =
        serde_json::to_string_pretty(&package_json).map_err(|source| CliError::CommandFailed {
            command: format!("failed to serialize package.json: {}", source),
            status: None,
        })?;
    let package_json_path = output_dir.join("package.json");
    std::fs::write(&package_json_path, rendered).map_err(|source| CliError::WriteFailed {
        path: package_json_path.clone(),
        source,
    })?;

    Ok(package_json_path)
}

pub(crate) fn generate_wasm_readme(
    config: &Config,
    module_name: &str,
    enabled_targets: &[WasmNpmTarget],
    output_dir: &Path,
) -> Result<PathBuf> {
    let package_name = config.wasm_npm_package_name().unwrap_or(module_name);
    let targets_text = enabled_targets
        .iter()
        .map(|target| match target {
            WasmNpmTarget::Bundler => "bundler",
            WasmNpmTarget::Web => "web",
            WasmNpmTarget::Nodejs => "nodejs",
        })
        .collect::<Vec<_>>()
        .join(", ");
    let content = format!(
        "# {package_name}\n\nGenerated by boltffi.\n\nEnabled wasm npm targets: {targets_text}\n\n```ts\nimport {{ initialized }} from \"{package_name}\";\nawait initialized;\n```\n"
    );

    let readme_path = output_dir.join("README.md");
    std::fs::write(&readme_path, content).map_err(|source| CliError::WriteFailed {
        path: readme_path.clone(),
        source,
    })?;

    Ok(readme_path)
}

#[derive(Serialize)]
pub(crate) struct WasmPackageJson {
    name: String,
    version: String,
    #[serde(rename = "type")]
    package_type: String,
    exports: WasmPackageExports,
    types: String,
    files: Vec<String>,
    dependencies: BTreeMap<String, String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    license: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    repository: Option<String>,
}

#[derive(Serialize)]
pub(crate) struct WasmPackageExports {
    #[serde(rename = ".")]
    root: WasmPackageEntry,
}

#[derive(Serialize)]
pub(crate) struct WasmPackageEntry {
    types: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    browser: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    node: Option<String>,
    default: String,
}

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

    use super::generate_wasm_loader_entrypoints;
    use crate::config::WasmNpmTarget;

    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 writes_browser_loader_entrypoints_without_escaped_source_noise() {
        let output_directory = unique_temp_dir("boltffi-wasm-browser-loader-test");
        fs::create_dir_all(&output_directory).expect("create output directory");

        generate_wasm_loader_entrypoints(
            "demo",
            &[WasmNpmTarget::Bundler, WasmNpmTarget::Web],
            &output_directory,
        )
        .expect("browser loader generation should succeed");

        let bundler_loader =
            fs::read_to_string(output_directory.join("bundler.js")).expect("read bundler loader");
        let web_loader =
            fs::read_to_string(output_directory.join("web.js")).expect("read web loader");

        assert!(bundler_loader.contains(r#"import init from "./demo.js";"#));
        assert!(bundler_loader.contains(r#"await init(response);"#));
        assert!(web_loader.contains(r#"new URL("./demo_bg.wasm", import.meta.url)"#));

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

    #[test]
    fn writes_node_loader_entrypoint_from_typed_target_mapping() {
        let output_directory = unique_temp_dir("boltffi-wasm-node-loader-test");
        fs::create_dir_all(&output_directory).expect("create output directory");

        generate_wasm_loader_entrypoints("demo", &[WasmNpmTarget::Nodejs], &output_directory)
            .expect("node loader generation should succeed");

        let node_loader =
            fs::read_to_string(output_directory.join("node.js")).expect("read node loader");

        assert!(node_loader.contains(r#"export * from "./demo_node.js";"#));
        assert!(node_loader.contains(r#"export { default, initialized } from "./demo_node.js";"#));

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