boltffi_cli 0.24.0

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

use serde::Deserialize;

use crate::cli::{CliError, Result};

use super::Cargo;

#[derive(Debug, Deserialize)]
pub(crate) struct CargoMetadata {
    pub(crate) packages: Vec<CargoMetadataPackage>,
    pub(crate) target_directory: PathBuf,
}

#[derive(Debug, Deserialize)]
pub(crate) struct CargoMetadataPackage {
    pub(crate) id: String,
    pub(crate) name: String,
    pub(crate) manifest_path: PathBuf,
    pub(crate) targets: Vec<CargoMetadataPackageTarget>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct CargoMetadataPackageTarget {
    pub(crate) name: String,
    pub(crate) crate_types: Vec<CargoCrateType>,
}

#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(from = "String")]
pub(crate) enum CargoCrateType {
    StaticLib,
    Cdylib,
    Dylib,
    Rlib,
    Bin,
    ProcMacro,
    Other(String),
}

impl CargoMetadata {
    pub(crate) fn load(cargo: &Cargo) -> Result<Self> {
        let mut command = Command::new("cargo");
        command.current_dir(cargo.working_directory());
        if let Some(toolchain_selector) = cargo.toolchain_selector() {
            command.arg(toolchain_selector);
        }
        let output = command
            .args(["metadata", "--format-version", "1", "--no-deps"])
            .args(cargo.metadata_arguments())
            .output()
            .map_err(|source| CliError::CommandFailed {
                command: format!("cargo metadata: {source}"),
                status: None,
            })?;

        if !output.status.success() {
            return Err(CliError::CommandFailed {
                command: "cargo metadata --format-version 1 --no-deps".to_string(),
                status: output.status.code(),
            });
        }

        Self::parse(&output.stdout)
    }

    pub(crate) fn find_package(
        &self,
        manifest_path: &Path,
        package_selector: Option<&str>,
    ) -> Result<&CargoMetadataPackage> {
        if let Some(package_selector) = package_selector {
            return self
                .packages
                .iter()
                .find(|package| package.matches_selector(package_selector))
                .ok_or_else(|| CliError::CommandFailed {
                    command: format!(
                        "could not find selected cargo package '{}' in cargo metadata",
                        package_selector
                    ),
                    status: None,
                });
        }

        self.packages
            .iter()
            .find(|package| package.manifest_path == manifest_path)
            .ok_or_else(|| CliError::CommandFailed {
                command: format!(
                    "could not find current package manifest '{}' in cargo metadata",
                    manifest_path.display()
                ),
                status: None,
            })
    }

    #[cfg(test)]
    pub(crate) fn target_directory_from_bytes(metadata: &[u8]) -> Result<PathBuf> {
        Ok(Self::parse(metadata)?.target_directory)
    }

    fn parse(metadata: &[u8]) -> Result<Self> {
        serde_json::from_slice::<Self>(metadata).map_err(|source| CliError::CommandFailed {
            command: format!("parse cargo metadata: {source}"),
            status: None,
        })
    }
}

impl CargoMetadataPackage {
    pub(crate) fn has_target(&self, target_name: &str) -> bool {
        self.targets.iter().any(|target| target.name == target_name)
    }

    pub(crate) fn resolve_library_artifact_name(
        &self,
        preferred_artifact_name: &str,
        manifest_path: &Path,
    ) -> Result<&str> {
        self.resolve_library_target(preferred_artifact_name, manifest_path)
            .map(|target| target.name.as_str())
    }

    pub(crate) fn resolve_library_target(
        &self,
        preferred_artifact_name: &str,
        manifest_path: &Path,
    ) -> Result<&CargoMetadataPackageTarget> {
        let ffi_targets = self
            .targets
            .iter()
            .filter(|target| target.builds_ffi())
            .collect::<Vec<_>>();

        if let Some(target) = ffi_targets
            .iter()
            .copied()
            .find(|target| target.name == preferred_artifact_name)
        {
            return Ok(target);
        }

        if ffi_targets.len() == 1 {
            return Ok(ffi_targets[0]);
        }

        Err(CliError::CommandFailed {
            command: format!(
                "could not find library target '{}' in cargo metadata for '{}'",
                preferred_artifact_name,
                manifest_path.display()
            ),
            status: None,
        })
    }

    fn matches_selector(&self, package_selector: &str) -> bool {
        self.name == package_selector
            || self.id == package_selector
            || self.matches_package_spec(package_selector)
    }

    fn matches_package_spec(&self, package_selector: &str) -> bool {
        let Some((name, version)) = package_selector.rsplit_once('@') else {
            return false;
        };

        self.name == name && self.version().is_some_and(|value| value == version)
    }

    fn version(&self) -> Option<&str> {
        let fragment = self.id.rsplit('#').next()?;
        let (_, version) = fragment.rsplit_once('@')?;
        Some(version)
    }
}

impl CargoMetadataPackageTarget {
    pub(crate) fn builds_ffi(&self) -> bool {
        self.builds_staticlib() || self.builds_cdylib()
    }

    pub(crate) fn builds_staticlib(&self) -> bool {
        self.crate_types.iter().any(CargoCrateType::is_staticlib)
    }

    pub(crate) fn builds_cdylib(&self) -> bool {
        self.crate_types.iter().any(CargoCrateType::is_cdylib)
    }
}

impl CargoCrateType {
    fn is_staticlib(&self) -> bool {
        matches!(self, Self::StaticLib)
    }

    fn is_cdylib(&self) -> bool {
        matches!(self, Self::Cdylib)
    }
}

impl From<String> for CargoCrateType {
    fn from(crate_type: String) -> Self {
        match crate_type.as_str() {
            "staticlib" => Self::StaticLib,
            "cdylib" => Self::Cdylib,
            "dylib" => Self::Dylib,
            "rlib" => Self::Rlib,
            "bin" => Self::Bin,
            "proc-macro" => Self::ProcMacro,
            _ => Self::Other(crate_type),
        }
    }
}

impl From<&str> for CargoCrateType {
    fn from(crate_type: &str) -> Self {
        Self::from(crate_type.to_string())
    }
}

impl From<CargoCrateType> for String {
    fn from(crate_type: CargoCrateType) -> Self {
        match crate_type {
            CargoCrateType::StaticLib => "staticlib".to_string(),
            CargoCrateType::Cdylib => "cdylib".to_string(),
            CargoCrateType::Dylib => "dylib".to_string(),
            CargoCrateType::Rlib => "rlib".to_string(),
            CargoCrateType::Bin => "bin".to_string(),
            CargoCrateType::ProcMacro => "proc-macro".to_string(),
            CargoCrateType::Other(crate_type) => crate_type,
        }
    }
}

#[cfg(test)]
mod tests {
    use std::path::{Path, PathBuf};

    use super::CargoMetadata;
    use crate::cargo::fixture::{CargoMetadataFixture, CargoPackageFixture};

    fn metadata_fixture() -> CargoMetadataFixture {
        CargoMetadataFixture::new("/tmp/boltffi-target")
    }

    #[test]
    fn parses_target_directory_from_cargo_metadata() {
        let target_directory =
            CargoMetadata::target_directory_from_bytes(&metadata_fixture().json_bytes())
                .expect("expected target directory");

        assert_eq!(target_directory, PathBuf::from("/tmp/boltffi-target"));
    }

    #[test]
    fn finds_current_cargo_metadata_package_by_manifest_path() {
        let metadata = metadata_fixture()
            .package(CargoPackageFixture::manifest_package(
                "workspace-a",
                "/tmp/workspace/a/Cargo.toml",
                "0.1.0",
            ))
            .package(CargoPackageFixture::manifest_package(
                "workspace-b",
                "/tmp/workspace/b/Cargo.toml",
                "0.1.0",
            ))
            .metadata();

        let package = metadata
            .find_package(Path::new("/tmp/workspace/b/Cargo.toml"), None)
            .expect("package lookup");

        assert_eq!(package.id, "path+file:///tmp/workspace/b#0.1.0");
    }

    #[test]
    fn finds_selected_cargo_metadata_package_by_package_name() {
        let metadata = metadata_fixture()
            .package(CargoPackageFixture::workspace_package(
                "workspace-a",
                "/tmp/workspace/Cargo.toml",
                "0.1.0",
            ))
            .package(CargoPackageFixture::workspace_package(
                "workspace-b",
                "/tmp/workspace/Cargo.toml",
                "0.1.0",
            ))
            .metadata();

        let package = metadata
            .find_package(Path::new("/tmp/workspace/Cargo.toml"), Some("workspace-b"))
            .expect("package lookup");

        assert_eq!(package.id, "path+file:///tmp/workspace#workspace-b@0.1.0");
    }

    #[test]
    fn finds_selected_cargo_metadata_package_by_package_spec() {
        let metadata = metadata_fixture()
            .package(CargoPackageFixture::workspace_package(
                "workspace-a",
                "/tmp/workspace/Cargo.toml",
                "0.1.0",
            ))
            .package(CargoPackageFixture::workspace_package(
                "workspace-b",
                "/tmp/workspace/Cargo.toml",
                "1.2.3",
            ))
            .metadata();

        let package = metadata
            .find_package(
                Path::new("/tmp/workspace/Cargo.toml"),
                Some("workspace-b@1.2.3"),
            )
            .expect("package lookup");

        assert_eq!(package.id, "path+file:///tmp/workspace#workspace-b@1.2.3");
    }
}