use std::path::{Path, PathBuf};
use std::process::Command;
use askama::Template;
use crate::build::{
CargoBuildProfile, OutputCallback, resolve_build_profile, run_command_streaming,
};
use crate::cargo::Cargo;
use crate::cli::{CliError, Result};
use crate::commands::generate::run_generate_csharp_with_output_from_source_dir;
use crate::commands::pack::PackCSharpOptions;
use crate::config::Config;
use crate::pack::{PackError, format_command_for_log, print_cargo_line, resolve_build_cargo_args};
use crate::reporter::{Reporter, Step};
use crate::target::{CSharpRuntimeIdentifier, NativeHostPlatform};
use crate::toolchain::NativeHostToolchain;
pub(crate) fn pack_csharp(
config: &Config,
options: PackCSharpOptions,
reporter: &Reporter,
) -> Result<()> {
if !config.is_csharp_enabled() {
return Err(CliError::CommandFailed {
command: "targets.csharp.enabled = false".to_string(),
status: None,
});
}
reporter.section("🔷", "Packing C#");
let step = reporter.step("Preparing C# packaging");
let plan = CSharpPackagingPlan::from_config(
config,
options.execution.release,
&options.execution.cargo_args,
options.execution.no_build,
)?;
step.finish_success();
sync_csharp_project(config, &plan)?;
remove_stale_csharp_runtime_assets(&plan)?;
if options.execution.regenerate {
let step = reporter.step("Generating C# bindings");
run_generate_csharp_with_output_from_source_dir(
config,
Some(plan.layout.source_directory.clone()),
&plan.source_directory,
&plan.artifact_name,
)?;
step.finish_success();
}
for packaging_target in &plan.packaging_targets {
let action = if options.execution.no_build {
"Reusing"
} else {
"Building"
};
let step = reporter.step(&format!(
"{action} Rust cdylib for {}",
packaging_target.runtime_identifier.canonical_name()
));
let native_library = if options.execution.no_build {
existing_csharp_native_library(packaging_target)?
} else {
build_csharp_native_library(packaging_target, &step)?
};
copy_csharp_native_asset(&plan.layout, packaging_target, &native_library)?;
step.finish_success_with(&format!("{}", native_library.display()));
}
let step = reporter.step("Building NuGet package");
let package_path = dotnet_pack(&plan, &step)?;
step.finish_success_with(&format!("{}", package_path.display()));
reporter.finish();
Ok(())
}
#[derive(Debug)]
struct CSharpPackageLayout {
root_directory: PathBuf,
source_directory: PathBuf,
package_output: PathBuf,
project_path: PathBuf,
}
#[derive(Debug)]
struct CSharpCargoContext {
rust_target_triple: String,
release: bool,
build_profile: CargoBuildProfile,
artifact_name: String,
cargo_manifest_path: PathBuf,
package_selector: Option<String>,
target_directory: PathBuf,
cargo_command_args: Vec<String>,
toolchain_selector: Option<String>,
}
#[derive(Debug)]
struct CSharpPackagingTarget {
runtime_identifier: CSharpRuntimeIdentifier,
host_target: NativeHostPlatform,
cargo_context: CSharpCargoContext,
toolchain: Option<NativeHostToolchain>,
}
#[derive(Debug)]
struct CSharpPackagingPlan {
package_id: String,
package_version: String,
target_framework: String,
artifact_name: String,
source_directory: PathBuf,
layout: CSharpPackageLayout,
packaging_targets: Vec<CSharpPackagingTarget>,
}
impl CSharpCargoContext {
fn artifact_directory(&self) -> PathBuf {
self.target_directory
.join(&self.rust_target_triple)
.join(self.build_profile.output_directory_name())
}
}
impl CSharpPackagingPlan {
fn from_config(
config: &Config,
release: bool,
cargo_args: &[String],
no_build: bool,
) -> Result<Self> {
let build_cargo_args = resolve_build_cargo_args(config, cargo_args);
let cargo = Cargo::current(&build_cargo_args)?;
ensure_csharp_pack_cargo_args_supported(&cargo)?;
let metadata = cargo.metadata()?;
let cargo_manifest_path = cargo.manifest_path()?;
let package_selector =
cargo.effective_package_selector(config, &metadata, &cargo_manifest_path);
let package = metadata.find_package(&cargo_manifest_path, package_selector.as_deref())?;
let artifact_name = package
.resolve_library_artifact_name(&config.crate_artifact_name(), &cargo_manifest_path)?
.to_string();
let library_target =
package.resolve_library_target(&artifact_name, &cargo_manifest_path)?;
if !library_target.builds_cdylib() {
return Err(CliError::CommandFailed {
command: "pack csharp requires the selected Rust library target to build a cdylib"
.to_string(),
status: None,
});
}
let source_directory = package
.manifest_path
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| CliError::CommandFailed {
command:
"could not resolve selected Cargo package source directory for C# generation"
.to_string(),
status: None,
})?;
let runtime_identifiers =
config
.csharp_runtime_identifiers()
.map_err(|message| CliError::CommandFailed {
command: message,
status: None,
})?;
let current_host = NativeHostPlatform::current().ok_or_else(|| CliError::CommandFailed {
command:
"C# packaging is only supported on osx-arm64, osx-x64, linux-x64, linux-arm64, and win-x64 hosts".to_string(),
status: None,
})?;
let build_profile = resolve_build_profile(release, &build_cargo_args);
let toolchain_selector = cargo.toolchain_selector().map(str::to_owned);
let cargo_command_args = cargo.probe_command_arguments();
let no_build_config_args = csharp_no_build_config_args(&cargo);
let packaging_targets = runtime_identifiers
.iter()
.copied()
.map(|runtime_identifier| {
let host_target = runtime_identifier.native_host_platform();
let (rust_target_triple, toolchain) = if no_build {
(
NativeHostToolchain::resolve_csharp_no_build_rust_target_triple(
toolchain_selector.as_deref(),
&no_build_config_args,
host_target,
current_host,
)?,
None,
)
} else {
let toolchain = NativeHostToolchain::discover_csharp(
toolchain_selector.as_deref(),
&cargo_command_args,
host_target,
current_host,
)?;
(toolchain.rust_target_triple().to_string(), Some(toolchain))
};
let cargo_context = CSharpCargoContext {
rust_target_triple,
release,
build_profile: build_profile.clone(),
artifact_name: artifact_name.clone(),
cargo_manifest_path: cargo_manifest_path.clone(),
package_selector: package_selector.clone(),
target_directory: metadata.target_directory.clone(),
cargo_command_args: cargo_command_args.clone(),
toolchain_selector: toolchain_selector.clone(),
};
Ok(CSharpPackagingTarget {
runtime_identifier,
host_target,
cargo_context,
toolchain,
})
})
.collect::<Result<Vec<_>>>()?;
let root_directory = config.csharp_output();
let layout = CSharpPackageLayout {
source_directory: root_directory.join("src"),
package_output: config.csharp_package_output(),
project_path: root_directory.join("BoltFFI.CSharp.csproj"),
root_directory,
};
Ok(Self {
package_id: config.csharp_package_id(),
package_version: config
.package_version()
.unwrap_or_else(|| "0.1.0".to_string()),
target_framework: config.csharp_target_framework(),
artifact_name,
source_directory,
layout,
packaging_targets,
})
}
}
fn csharp_no_build_config_args(cargo: &Cargo) -> Vec<String> {
cargo.command_arguments_without_target_selector()
}
fn ensure_csharp_pack_cargo_args_supported(cargo: &Cargo) -> Result<()> {
if let Some(target_selector) = cargo.target_selector() {
return Err(CliError::CommandFailed {
command: format!(
"pack csharp resolves native assets from targets.csharp.runtime_identifiers; remove cargo --target '{}'",
target_selector
),
status: None,
});
}
Ok(())
}
fn build_csharp_native_library(
packaging_target: &CSharpPackagingTarget,
step: &Step,
) -> Result<PathBuf> {
let cargo_context = &packaging_target.cargo_context;
let verbose = step.is_verbose();
let on_output: Option<OutputCallback> = Some(Box::new(move |line: &str| {
if verbose {
print_cargo_line(line);
}
}));
let crate_directory = std::env::current_dir().map_err(|source| CliError::CommandFailed {
command: format!("current_dir: {source}"),
status: None,
})?;
let mut command = Command::new("cargo");
command.current_dir(crate_directory);
if let Some(toolchain_selector) = cargo_context.toolchain_selector.as_deref() {
command.arg(toolchain_selector);
}
command
.arg("build")
.arg("--target")
.arg(&cargo_context.rust_target_triple)
.arg("--manifest-path")
.arg(&cargo_context.cargo_manifest_path);
if let Some(package_selector) = cargo_context.package_selector.as_deref() {
command.arg("-p").arg(package_selector);
}
if cargo_context.release {
command.arg("--release");
}
command.args(&cargo_context.cargo_command_args);
packaging_target
.toolchain
.as_ref()
.ok_or_else(|| CliError::CommandFailed {
command: "internal error: C# build requested without a discovered native toolchain"
.to_string(),
status: None,
})?
.configure_cargo_build(&mut command);
if step.is_verbose() {
crate::pack::print_verbose_detail(&format!(
"Cargo build command: {}",
format_command_for_log(&command)
));
}
if !run_command_streaming(&mut command, on_output.as_ref()) {
return Err(PackError::BuildFailed {
targets: vec![
packaging_target
.runtime_identifier
.canonical_name()
.to_string(),
],
}
.into());
}
existing_csharp_native_library(packaging_target)
}
fn existing_csharp_native_library(packaging_target: &CSharpPackagingTarget) -> Result<PathBuf> {
let cargo_context = &packaging_target.cargo_context;
let native_library = cargo_context.artifact_directory().join(
packaging_target
.host_target
.shared_library_filename(&cargo_context.artifact_name),
);
if native_library.exists() {
return Ok(native_library);
}
Err(CliError::FileNotFound(native_library))
}
fn copy_csharp_native_asset(
layout: &CSharpPackageLayout,
packaging_target: &CSharpPackagingTarget,
native_library: &Path,
) -> Result<PathBuf> {
let output_directory = layout
.root_directory
.join("runtimes")
.join(packaging_target.runtime_identifier.canonical_name())
.join("native");
std::fs::create_dir_all(&output_directory).map_err(|source| {
CliError::CreateDirectoryFailed {
path: output_directory.clone(),
source,
}
})?;
let output_path = output_directory.join(
native_library
.file_name()
.expect("native library should have a filename"),
);
std::fs::copy(native_library, &output_path).map_err(|source| CliError::CopyFailed {
from: native_library.to_path_buf(),
to: output_path.clone(),
source,
})?;
Ok(output_path)
}
fn remove_stale_csharp_runtime_assets(plan: &CSharpPackagingPlan) -> Result<()> {
let requested = plan
.packaging_targets
.iter()
.map(|target| target.runtime_identifier)
.collect::<std::collections::HashSet<_>>();
let runtimes_root = plan.layout.root_directory.join("runtimes");
for runtime_identifier in CSharpRuntimeIdentifier::EXPLICIT_TARGETS {
if requested.contains(runtime_identifier) {
continue;
}
let runtime_dir = runtimes_root.join(runtime_identifier.canonical_name());
match std::fs::remove_dir_all(&runtime_dir) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(source) => {
return Err(CliError::WriteFailed {
path: runtime_dir,
source,
});
}
}
}
Ok(())
}
fn sync_csharp_project(config: &Config, plan: &CSharpPackagingPlan) -> Result<()> {
std::fs::create_dir_all(&plan.layout.root_directory).map_err(|source| {
CliError::CreateDirectoryFailed {
path: plan.layout.root_directory.clone(),
source,
}
})?;
std::fs::create_dir_all(&plan.layout.source_directory).map_err(|source| {
CliError::CreateDirectoryFailed {
path: plan.layout.source_directory.clone(),
source,
}
})?;
std::fs::create_dir_all(&plan.layout.package_output).map_err(|source| {
CliError::CreateDirectoryFailed {
path: plan.layout.package_output.clone(),
source,
}
})?;
let project = render_csharp_project(config, plan)?;
std::fs::write(&plan.layout.project_path, project).map_err(|source| CliError::WriteFailed {
path: plan.layout.project_path.clone(),
source,
})
}
#[derive(Template)]
#[template(path = "BoltFFI.CSharp.csproj.xml", escape = "html")]
struct CSharpProjectTemplate<'a> {
target_framework: &'a str,
runtime_identifiers: &'a [CSharpRuntimeIdentifier],
package_id: &'a str,
package_version: &'a str,
nuget: &'a CSharpNugetProjectMetadata,
}
#[derive(Default)]
struct CSharpNugetProjectMetadata {
title: Option<String>,
authors: Option<Vec<String>>,
owners: Option<Vec<String>>,
description: Option<String>,
package_project_url: Option<String>,
repository_url: Option<String>,
repository_type: Option<String>,
package_license_expression: Option<String>,
package_icon: Option<CSharpPackageFile>,
package_readme_file: Option<CSharpPackageFile>,
package_tags: Option<Vec<String>>,
package_release_notes: Option<String>,
package_require_license_acceptance: Option<bool>,
}
struct CSharpPackageFile {
include_path: PathBuf,
package_path: PathBuf,
}
impl CSharpNugetProjectMetadata {
fn from_config(config: &Config, plan: &CSharpPackagingPlan) -> Result<Self> {
let nuget = &config.targets.csharp.nuget;
Ok(Self {
title: nuget.title.clone(),
authors: nuget.authors.clone(),
owners: nuget.owners.clone(),
description: config.package.description.clone(),
package_project_url: nuget.project_url.clone(),
repository_url: nuget
.repository_url
.clone()
.or_else(|| config.package_repository()),
repository_type: nuget.repository_type.clone(),
package_license_expression: nuget
.license_expression
.clone()
.or_else(|| config.package_license()),
package_icon: nuget
.icon
.as_deref()
.map(|path| CSharpPackageFile::from_config_path(&plan.layout.root_directory, path))
.transpose()?,
package_readme_file: nuget
.readme
.as_deref()
.map(|path| CSharpPackageFile::from_config_path(&plan.layout.root_directory, path))
.transpose()?,
package_tags: nuget.tags.clone(),
package_release_notes: nuget.release_notes.clone(),
package_require_license_acceptance: nuget.require_license_acceptance,
})
}
fn title(&self) -> Option<&str> {
self.title.as_deref()
}
fn authors(&self) -> Option<&[String]> {
self.authors.as_deref()
}
fn owners(&self) -> Option<&[String]> {
self.owners.as_deref()
}
fn description(&self) -> Option<&str> {
self.description.as_deref()
}
fn package_project_url(&self) -> Option<&str> {
self.package_project_url.as_deref()
}
fn repository_url(&self) -> Option<&str> {
self.repository_url.as_deref()
}
fn repository_type(&self) -> Option<&str> {
self.repository_type.as_deref()
}
fn package_license_expression(&self) -> Option<&str> {
self.package_license_expression.as_deref()
}
fn package_icon(&self) -> Option<&CSharpPackageFile> {
self.package_icon.as_ref()
}
fn package_readme_file(&self) -> Option<&CSharpPackageFile> {
self.package_readme_file.as_ref()
}
fn package_tags(&self) -> Option<&[String]> {
self.package_tags.as_deref()
}
fn package_release_notes(&self) -> Option<&str> {
self.package_release_notes.as_deref()
}
fn package_require_license_acceptance(&self) -> Option<bool> {
self.package_require_license_acceptance
}
}
impl CSharpPackageFile {
fn from_config_path(project_root: &Path, configured_path: &Path) -> Result<Self> {
Ok(Self {
include_path: project_relative_config_path(project_root, configured_path)?,
package_path: package_file_name(configured_path)?,
})
}
fn include_path(&self) -> std::path::Display<'_> {
self.include_path.display()
}
fn package_path(&self) -> std::path::Display<'_> {
self.package_path.display()
}
}
fn render_csharp_project(config: &Config, plan: &CSharpPackagingPlan) -> Result<String> {
let runtime_identifiers = plan
.packaging_targets
.iter()
.map(|target| target.runtime_identifier)
.collect::<Vec<_>>();
let nuget = CSharpNugetProjectMetadata::from_config(config, plan)?;
CSharpProjectTemplate {
target_framework: &plan.target_framework,
runtime_identifiers: &runtime_identifiers,
package_id: &plan.package_id,
package_version: &plan.package_version,
nuget: &nuget,
}
.render()
.map_err(|source| CliError::CommandFailed {
command: format!("render C# project template: {source}"),
status: None,
})
}
fn package_file_name(path: &Path) -> Result<PathBuf> {
path.file_name()
.map(PathBuf::from)
.ok_or_else(|| CliError::CommandFailed {
command: "C# NuGet package files must include a file name".to_string(),
status: None,
})
}
fn project_relative_config_path(project_root: &Path, configured_path: &Path) -> Result<PathBuf> {
let current_dir = std::env::current_dir().map_err(|source| CliError::CommandFailed {
command: format!("current_dir: {source}"),
status: None,
})?;
let from_dir = absolute_from_current_dir(¤t_dir, project_root);
let to_path = absolute_from_current_dir(¤t_dir, configured_path);
Ok(relative_path(&from_dir, &to_path))
}
fn absolute_from_current_dir(current_dir: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
current_dir.join(path)
}
}
fn relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
if from_dir == Path::new(".") || from_dir == 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()
}
fn dotnet_pack(plan: &CSharpPackagingPlan, step: &Step) -> Result<PathBuf> {
let mut command = Command::new("dotnet");
command
.arg("pack")
.arg(&plan.layout.project_path)
.arg("--configuration")
.arg(
if plan
.packaging_targets
.iter()
.any(|target| target.cargo_context.build_profile.is_release_like())
{
"Release"
} else {
"Debug"
},
)
.arg("--output")
.arg(&plan.layout.package_output)
.arg("--ignore-failed-sources")
.arg("-p:NuGetAudit=false")
.arg("-p:RuntimeIdentifiers=")
.arg("-p:RuntimeIdentifier=")
.arg("--nologo");
if step.is_verbose() {
crate::pack::print_verbose_detail(&format!(
"dotnet pack command: {}",
format_command_for_log(&command)
));
}
let status = command.status().map_err(|source| CliError::CommandFailed {
command: format!("dotnet pack: {source}"),
status: None,
})?;
if !status.success() {
return Err(CliError::CommandFailed {
command: "dotnet pack".to_string(),
status: status.code(),
});
}
Ok(plan.layout.package_output.join(format!(
"{}.{}.nupkg",
plan.package_id, plan.package_version
)))
}
#[cfg(test)]
mod tests {
use super::{
CSharpPackageLayout, CSharpPackagingPlan, csharp_no_build_config_args, pack_csharp,
render_csharp_project,
};
use crate::cargo::{Cargo, CargoCrateType};
use crate::cli::CliError;
use crate::commands::pack::{PackCSharpOptions, PackExecutionOptions};
use crate::config::{
CSharpConfig, CSharpNugetConfig, CargoConfig, Config, PackageConfig, TargetsConfig,
};
use crate::reporter::{Reporter, Verbosity};
use crate::target::{CSharpRuntimeIdentifier, NativeHostPlatform};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
struct CargoManifestBuilder {
package_name: String,
package_version: String,
edition: String,
crate_types: Vec<CargoCrateType>,
}
impl CargoManifestBuilder {
fn cdylib_package(package_name: impl Into<String>) -> Self {
Self {
package_name: package_name.into(),
package_version: "0.1.0".to_string(),
edition: "2021".to_string(),
crate_types: vec![CargoCrateType::Cdylib],
}
}
fn render(&self) -> String {
let mut manifest = toml::Table::new();
let mut package = toml::Table::new();
package.insert(
"name".to_string(),
toml::Value::String(self.package_name.clone()),
);
package.insert(
"version".to_string(),
toml::Value::String(self.package_version.clone()),
);
package.insert(
"edition".to_string(),
toml::Value::String(self.edition.clone()),
);
manifest.insert("package".to_string(), toml::Value::Table(package));
let mut lib = toml::Table::new();
lib.insert(
"crate-type".to_string(),
toml::Value::Array(
self.crate_types
.iter()
.cloned()
.map(String::from)
.map(toml::Value::String)
.collect(),
),
);
manifest.insert("lib".to_string(), toml::Value::Table(lib));
toml::to_string(&toml::Value::Table(manifest)).expect("temp Cargo manifest TOML")
}
}
fn config() -> Config {
Config {
experimental: Vec::new(),
cargo: CargoConfig::default(),
package: PackageConfig {
name: "demo".to_string(),
crate_name: None,
version: Some("1.2.3".to_string()),
description: Some("Demo <runtime>".to_string()),
license: Some("MIT".to_string()),
repository: Some("https://example.com/demo".to_string()),
},
targets: TargetsConfig {
csharp: CSharpConfig {
enabled: true,
runtime_identifiers: Some(vec![
CSharpRuntimeIdentifier::OsxArm64,
CSharpRuntimeIdentifier::LinuxX64,
]),
..Default::default()
},
..Default::default()
},
}
}
fn options() -> PackCSharpOptions {
PackCSharpOptions {
execution: PackExecutionOptions {
release: false,
regenerate: true,
no_build: false,
cargo_args: Vec::new(),
},
}
}
fn unique_temp_dir(prefix: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_nanos();
std::env::temp_dir().join(format!("{prefix}-{unique}"))
}
fn temp_cdylib_project() -> PathBuf {
let root = unique_temp_dir("boltffi-csharp-plan-test");
let src = root.join("src");
std::fs::create_dir_all(&src).expect("create temp cdylib source dir");
std::fs::write(
root.join("Cargo.toml"),
CargoManifestBuilder::cdylib_package("demo").render(),
)
.expect("write temp cdylib manifest");
std::fs::write(src.join("lib.rs"), "pub extern \"C\" fn demo_noop() {}\n")
.expect("write temp cdylib lib");
root
}
fn cargo_args_for_manifest(manifest_path: &Path) -> Vec<String> {
vec![
"--manifest-path".to_string(),
manifest_path.display().to_string(),
]
}
fn csharp_packaging_plan(root_directory: PathBuf) -> CSharpPackagingPlan {
CSharpPackagingPlan {
package_id: "Demo.Runtime".to_string(),
package_version: "1.2.3".to_string(),
target_framework: "net10.0".to_string(),
artifact_name: "demo".to_string(),
source_directory: PathBuf::from("/tmp/demo"),
layout: CSharpPackageLayout {
source_directory: root_directory.join("src"),
package_output: root_directory.join("packages"),
project_path: root_directory.join("BoltFFI.CSharp.csproj"),
root_directory,
},
packaging_targets: Vec::new(),
}
}
fn unsupported_csharp_runtime_identifier() -> CSharpRuntimeIdentifier {
match NativeHostPlatform::current().expect("supported host for C# packaging tests") {
NativeHostPlatform::WindowsX86_64 => CSharpRuntimeIdentifier::OsxArm64,
_ => CSharpRuntimeIdentifier::WinX64,
}
}
fn expected_no_build_rust_target_triple(
runtime_identifier: CSharpRuntimeIdentifier,
) -> &'static str {
match runtime_identifier {
CSharpRuntimeIdentifier::Current => {
unreachable!("resolved runtime identifier required")
}
CSharpRuntimeIdentifier::OsxArm64 => "aarch64-apple-darwin",
CSharpRuntimeIdentifier::OsxX64 => "x86_64-apple-darwin",
CSharpRuntimeIdentifier::LinuxX64 => "x86_64-unknown-linux-gnu",
CSharpRuntimeIdentifier::LinuxArm64 => "aarch64-unknown-linux-gnu",
CSharpRuntimeIdentifier::WinX64 => "x86_64-pc-windows-msvc",
}
}
#[test]
fn csharp_no_build_config_args_preserve_manifest_path_when_probe_args_strip_it() {
let cargo_args = vec![
"--manifest-path".to_string(),
"/tmp/demo/Cargo.toml".to_string(),
"--target".to_string(),
"x86_64-unknown-linux-gnu".to_string(),
"--config=build.target='x86_64-pc-windows-gnu'".to_string(),
];
let cargo = Cargo::in_working_directory(PathBuf::from("/workspace"), &cargo_args);
let config_args = csharp_no_build_config_args(&cargo);
let probe_args = cargo.probe_command_arguments();
assert!(
config_args
.windows(2)
.any(|args| { args[0] == "--manifest-path" && args[1] == "/tmp/demo/Cargo.toml" })
);
assert!(
config_args
.iter()
.all(|arg| arg != "--target" && !arg.starts_with("--target="))
);
assert!(config_args.contains(&"--config=build.target='x86_64-pc-windows-gnu'".to_string()));
assert!(
probe_args
.iter()
.all(|arg| arg != "--manifest-path" && !arg.starts_with("--manifest-path="))
);
}
#[test]
fn csharp_runtime_identifier_maps_native_assets() {
assert_eq!(
CSharpRuntimeIdentifier::OsxArm64.native_library_filename("demo"),
"libdemo.dylib"
);
assert_eq!(
CSharpRuntimeIdentifier::LinuxX64.native_library_filename("demo"),
"libdemo.so"
);
assert_eq!(
CSharpRuntimeIdentifier::WinX64.native_library_filename("demo"),
"demo.dll"
);
}
#[test]
fn csharp_project_includes_packable_native_assets() {
let plan = csharp_packaging_plan(PathBuf::from("/tmp/dist/csharp"));
let project = render_csharp_project(&config(), &plan).expect("C# project should render");
assert!(project.contains("<TargetFramework>net10.0</TargetFramework>"));
assert!(project.contains("<RuntimeIdentifiers></RuntimeIdentifiers>"));
assert!(project.contains("<PackageId>Demo.Runtime</PackageId>"));
assert!(project.contains("<Version>1.2.3</Version>"));
assert!(project.contains("<AllowUnsafeBlocks>true</AllowUnsafeBlocks>"));
assert!(project.contains(r#"<Compile Include="src/**/*.cs" />"#));
assert!(
project.contains(
r#"<None Include="runtimes/**/*" Pack="true" PackagePath="%(Identity)" />"#
)
);
assert!(project.contains("<Description>Demo <runtime></Description>"));
assert!(project.contains("<PackageLicenseExpression>MIT</PackageLicenseExpression>"));
assert!(project.contains("<RepositoryUrl>https://example.com/demo</RepositoryUrl>"));
}
#[test]
fn csharp_project_renders_configured_nuget_metadata() {
let mut config = config();
config.package.license = Some("Apache-2.0".to_string());
config.package.repository = Some("https://example.com/default".to_string());
config.targets.csharp.nuget = CSharpNugetConfig {
title: Some("Company MyLib".to_string()),
authors: Some(vec!["Company Name".to_string(), "Runtime Team".to_string()]),
owners: Some(vec!["Company Name".to_string()]),
project_url: Some("https://company.example/my-lib".to_string()),
repository_url: Some("https://github.com/company/my-lib".to_string()),
repository_type: Some("git".to_string()),
license_expression: Some("MIT".to_string()),
icon: Some(PathBuf::from("assets/icon.png")),
readme: Some(PathBuf::from("docs/README.md")),
tags: Some(vec![
"ffi".to_string(),
"rust".to_string(),
"native".to_string(),
]),
release_notes: Some("Initial C# bindings package.".to_string()),
require_license_acceptance: Some(false),
};
let plan = csharp_packaging_plan(PathBuf::from("dist/csharp"));
let project = render_csharp_project(&config, &plan).expect("C# project should render");
assert!(project.contains("<Title>Company MyLib</Title>"));
assert!(project.contains("<Authors>Company Name;Runtime Team</Authors>"));
assert!(project.contains("<Owners>Company Name</Owners>"));
assert!(project.contains("<Description>Demo <runtime></Description>"));
assert!(
project
.contains("<PackageProjectUrl>https://company.example/my-lib</PackageProjectUrl>")
);
assert!(
project.contains("<RepositoryUrl>https://github.com/company/my-lib</RepositoryUrl>")
);
assert!(project.contains("<RepositoryType>git</RepositoryType>"));
assert!(project.contains("<PackageLicenseExpression>MIT</PackageLicenseExpression>"));
assert!(project.contains("<PackageIcon>icon.png</PackageIcon>"));
assert!(project.contains("<PackageReadmeFile>README.md</PackageReadmeFile>"));
assert!(project.contains("<PackageTags>ffi;rust;native</PackageTags>"));
assert!(
project.contains(
"<PackageReleaseNotes>Initial C# bindings package.</PackageReleaseNotes>"
)
);
assert!(
project.contains(
"<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>"
)
);
assert!(
project
.contains(r#"<None Include="../../assets/icon.png" Pack="true" PackagePath="" />"#)
);
assert!(
project
.contains(r#"<None Include="../../docs/README.md" Pack="true" PackagePath="" />"#)
);
assert!(!project.contains("https://example.com/default"));
assert!(
!project.contains("<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>")
);
}
#[test]
fn rejects_pack_csharp_when_target_is_disabled() {
let mut config = config();
config.targets.csharp.enabled = false;
let error = pack_csharp(&config, options(), &Reporter::new(Verbosity::Quiet))
.expect_err("disabled csharp target should fail before packaging");
assert!(matches!(
error,
CliError::CommandFailed { command, .. }
if command.contains("targets.csharp.enabled = false")
));
}
#[test]
fn csharp_no_build_plan_allows_cross_host_runtime_identifiers() {
let project = temp_cdylib_project();
let manifest_path = project.join("Cargo.toml");
let mut config = config();
let runtime_identifier = unsupported_csharp_runtime_identifier();
config.targets.csharp.runtime_identifiers = Some(vec![runtime_identifier]);
let plan = CSharpPackagingPlan::from_config(
&config,
false,
&cargo_args_for_manifest(&manifest_path),
true,
)
.expect("no-build C# packaging should not validate cross-host build toolchains");
let packaging_target = plan
.packaging_targets
.first()
.expect("expected one C# packaging target");
assert_eq!(plan.packaging_targets.len(), 1);
assert_eq!(packaging_target.runtime_identifier, runtime_identifier);
assert!(packaging_target.toolchain.is_none());
assert_eq!(
packaging_target.cargo_context.rust_target_triple,
expected_no_build_rust_target_triple(runtime_identifier)
);
std::fs::remove_dir_all(project).expect("cleanup temp cdylib project");
}
#[test]
fn csharp_build_plan_rejects_unsupported_cross_host_runtime_identifiers() {
let project = temp_cdylib_project();
let manifest_path = project.join("Cargo.toml");
let mut config = config();
config.targets.csharp.runtime_identifiers =
Some(vec![unsupported_csharp_runtime_identifier()]);
let error = CSharpPackagingPlan::from_config(
&config,
false,
&cargo_args_for_manifest(&manifest_path),
false,
)
.expect_err("building C# packaging should still reject unsupported cross-host targets");
assert!(matches!(
error,
CliError::CommandFailed { command, .. }
if command.contains("is not supported from current host")
));
std::fs::remove_dir_all(project).expect("cleanup temp cdylib project");
}
}