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\""));
}
}