use std::path::PathBuf;
use crate::cli::{CliError, Result};
use crate::config::{Config, SpmDistribution, SpmLayout};
pub struct SpmPackageGenerator<'a> {
config: &'a Config,
xcframework_name: String,
checksum: Option<String>,
version: Option<String>,
layout: SpmLayout,
}
impl<'a> SpmPackageGenerator<'a> {
pub fn new_local(config: &'a Config, layout: SpmLayout) -> Self {
Self {
config,
xcframework_name: config.xcframework_name(),
checksum: None,
version: None,
layout,
}
}
pub fn new_remote(
config: &'a Config,
checksum: String,
version: String,
layout: SpmLayout,
) -> Self {
Self {
config,
xcframework_name: config.xcframework_name(),
checksum: Some(checksum),
version: Some(version),
layout,
}
}
pub fn generate(&self) -> Result<PathBuf> {
let output_path = self.config.apple_spm_output().join("Package.swift");
let content = match self.config.apple_spm_distribution() {
SpmDistribution::Local => self.generate_local_package(),
SpmDistribution::Remote => self.generate_remote_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 generate_local_package(&self) -> String {
let layout = self.layout;
let package_name = self.package_name_for_layout(layout);
let module_name = self.config.swift_module_name();
let tools_version = self.config.apple_swift_tools_version().unwrap_or("5.9");
let wrapper_sources = self.wrapper_sources_path(layout);
let binary_target_name = format!("{}FFI", self.xcframework_name);
let xcframework_path = self.local_xcframework_path();
if matches!(layout, SpmLayout::Split) {
return format!(
r#"// swift-tools-version:{tools_version}
import PackageDescription
let package = Package(
name: "{package_name}",
platforms: [
{platforms}
],
products: [
.library(
name: "{package_name}",
targets: ["{binary_target_name}"]
),
],
targets: [
.binaryTarget(
name: "{binary_target_name}",
path: "{xcframework_path}"
),
]
)
"#,
tools_version = tools_version,
package_name = package_name,
platforms = self.platforms_fragment(),
binary_target_name = binary_target_name,
xcframework_path = xcframework_path,
);
}
format!(
r#"// swift-tools-version:{tools_version}
import PackageDescription
let package = Package(
name: "{package_name}",
platforms: [
{platforms}
],
products: [
.library(
name: "{package_name}",
targets: ["{module_name}"]
),
],
targets: [
.binaryTarget(
name: "{binary_target_name}",
path: "{xcframework_path}"
),
.target(
name: "{module_name}",
dependencies: ["{binary_target_name}"],
path: "{wrapper_sources}"
),
]
)
"#,
tools_version = tools_version,
package_name = package_name,
module_name = module_name,
platforms = self.platforms_fragment(),
binary_target_name = binary_target_name,
xcframework_path = xcframework_path,
wrapper_sources = wrapper_sources,
)
}
fn generate_remote_package(&self) -> Result<String> {
let layout = self.layout;
let package_name = self.package_name_for_layout(layout);
let module_name = self.config.swift_module_name();
let tools_version = self.config.apple_swift_tools_version().unwrap_or("5.9");
let repo_url = self
.config
.apple_spm_repo_url()
.unwrap_or("https://github.com/user/repo");
let wrapper_sources = self.wrapper_sources_path(layout);
let binary_target_name = format!("{}FFI", self.xcframework_name);
let checksum = self
.checksum
.clone()
.ok_or_else(|| CliError::CommandFailed {
command: "missing checksum for remote SPM package".to_string(),
status: None,
})?;
let version = self
.version
.clone()
.ok_or_else(|| CliError::CommandFailed {
command: "missing version for remote SPM package".to_string(),
status: None,
})?;
if matches!(layout, SpmLayout::Split) {
return Ok(format!(
r#"// swift-tools-version:{tools_version}
import PackageDescription
let releaseTag = "{version}"
let releaseChecksum = "{checksum}"
let package = Package(
name: "{package_name}",
platforms: [
{platforms}
],
products: [
.library(
name: "{package_name}",
targets: ["{binary_target_name}"]
),
],
targets: [
.binaryTarget(
name: "{binary_target_name}",
url: "{repo_url}/releases/download/\(releaseTag)/{xcframework_name}.xcframework.zip",
checksum: releaseChecksum
),
]
)
"#,
tools_version = tools_version,
version = version,
checksum = checksum,
package_name = package_name,
platforms = self.platforms_fragment(),
binary_target_name = binary_target_name,
repo_url = repo_url,
xcframework_name = self.xcframework_name,
));
}
Ok(format!(
r#"// swift-tools-version:{tools_version}
import PackageDescription
let releaseTag = "{version}"
let releaseChecksum = "{checksum}"
let package = Package(
name: "{package_name}",
platforms: [
{platforms}
],
products: [
.library(
name: "{package_name}",
targets: ["{module_name}"]
),
],
targets: [
.binaryTarget(
name: "{binary_target_name}",
url: "{repo_url}/releases/download/\(releaseTag)/{xcframework_name}.xcframework.zip",
checksum: releaseChecksum
),
.target(
name: "{module_name}",
dependencies: ["{binary_target_name}"],
path: "{wrapper_sources}"
),
]
)
"#,
tools_version = tools_version,
version = version,
checksum = checksum,
package_name = package_name,
module_name = module_name,
platforms = self.platforms_fragment(),
repo_url = repo_url,
xcframework_name = self.xcframework_name,
binary_target_name = binary_target_name,
wrapper_sources = wrapper_sources,
))
}
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 platforms_fragment(&self) -> 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.join(",\n")
}
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.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 => format!("{}FFI", self.config.swift_module_name()),
SpmLayout::Bundled | SpmLayout::FfiOnly => self.config.swift_module_name(),
})
}
}
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 super::*;
fn parse_config(input: &str) -> Config {
let parsed: Config = toml::from_str(input).expect("toml parse failed");
parsed.validate().expect("config validation failed");
parsed
}
#[test]
fn spm_omits_macos_platform_when_enabled_without_macos_slices() {
let config = parse_config(
r#"
[package]
name = "mylib"
[targets.apple]
include_macos = true
macos_architectures = []
"#,
);
let package =
SpmPackageGenerator::new_local(&config, SpmLayout::FfiOnly).generate_local_package();
assert!(package.contains(".iOS(.v16)"));
assert!(!package.contains(".macOS(.v13)"));
}
#[test]
fn spm_omits_ios_platform_for_macos_only_packaging() {
let config = parse_config(
r#"
[package]
name = "mylib"
[targets.apple]
include_macos = true
ios_architectures = []
simulator_architectures = []
macos_architectures = ["arm64"]
"#,
);
let package =
SpmPackageGenerator::new_local(&config, SpmLayout::FfiOnly).generate_local_package();
assert!(!package.contains(".iOS(.v16)"));
assert!(package.contains(".macOS(.v13)"));
}
#[test]
fn spm_keeps_ios_platform_for_simulator_only_packaging() {
let config = parse_config(
r#"
[package]
name = "mylib"
[targets.apple]
ios_architectures = []
simulator_architectures = ["arm64"]
"#,
);
let package =
SpmPackageGenerator::new_local(&config, SpmLayout::FfiOnly).generate_local_package();
assert!(package.contains(".iOS(.v16)"));
assert!(!package.contains(".macOS(.v13)"));
}
}