use super::PackageArtifact;
use crate::core::config::ResolvedCrateConfig;
use crate::publish::package::BuildProfile;
use crate::publish::platform::{Os, RustTarget};
use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
pub fn package_ruby(
config: &ResolvedCrateConfig,
target: &RustTarget,
workspace_root: &Path,
output_dir: &Path,
version: &str,
) -> Result<PackageArtifact> {
let gem_name = config.ruby_gem_name();
let platform = target.platform_for(crate::core::config::extras::Language::Ruby);
let pkg_dir_str = config.package_dir(crate::core::config::extras::Language::Ruby);
let pkg_dir = workspace_root.join(&pkg_dir_str);
if !pkg_dir.exists() {
anyhow::bail!("Ruby package directory does not exist: {}", pkg_dir.display());
}
let rb_crate = crate::publish::crate_name_from_output(config, crate::core::config::extras::Language::Ruby)
.unwrap_or_else(|| format!("{}-rb", config.name));
let ext_name = rb_crate.replace('-', "_");
let cargo_lib_filename = target.shared_lib_name(&ext_name);
let native_lib = find_ruby_native_lib(workspace_root, target, &rb_crate, &cargo_lib_filename)?;
let packaged_lib_filename = ruby_extension_filename(target, &ext_name);
let ruby_abi = ruby_abi_for_packaging()?;
let lib_dest_dir = pkg_dir.join("lib").join(&ext_name).join(&ruby_abi);
fs::create_dir_all(&lib_dest_dir).with_context(|| format!("creating {}", lib_dest_dir.display()))?;
let lib_dest = lib_dest_dir.join(&packaged_lib_filename);
fs::copy(&native_lib, &lib_dest).with_context(|| format!("copying native lib to {}", lib_dest.display()))?;
let lib_dir = pkg_dir.join("lib");
let mut rb_files: Vec<String> = scan_rb_files(&lib_dir)
.with_context(|| format!("scanning Ruby wrapper sources under {}", lib_dir.display()))?
.into_iter()
.filter_map(|p| p.strip_prefix(&pkg_dir).ok().map(|r| r.to_string_lossy().into_owned()))
.collect();
rb_files.sort();
let native_lib_path = format!("lib/{ext_name}/{ruby_abi}/{packaged_lib_filename}");
if !rb_files.contains(&native_lib_path) {
rb_files.push(native_lib_path);
}
let source_gemspec_name = format!("{gem_name}.gemspec");
anyhow::ensure!(
pkg_dir.join(&source_gemspec_name).is_file(),
"ruby: missing source gemspec {}",
pkg_dir.join(&source_gemspec_name).display()
);
let gemspec_name = format!("{gem_name}-platform.gemspec");
let gemspec_path = pkg_dir.join(&gemspec_name);
let platform_gemspec = generate_platform_gemspec(&source_gemspec_name, version, &platform, &rb_files)?;
fs::write(&gemspec_path, platform_gemspec)?;
let gem_filename = format!("{gem_name}-{version}-{platform}.gem");
fs::create_dir_all(output_dir).with_context(|| format!("creating {}", output_dir.display()))?;
let dest = output_dir
.canonicalize()
.with_context(|| format!("resolving {}", output_dir.display()))?
.join(&gem_filename);
let build_result = run_gem_build(&pkg_dir, &gemspec_name, &dest);
let gemspec_cleanup = fs::remove_file(&gemspec_path);
let native_cleanup = fs::remove_file(&lib_dest);
if build_result.is_err() {
let _ = fs::remove_file(&dest);
}
build_result?;
gemspec_cleanup.with_context(|| format!("removing staged gemspec {}", gemspec_path.display()))?;
native_cleanup.with_context(|| format!("removing staged native library {}", lib_dest.display()))?;
Ok(PackageArtifact {
path: dest,
name: gem_filename,
checksum: None,
})
}
fn ruby_extension_filename(target: &RustTarget, extension_name: &str) -> String {
let extension = match target.os {
Os::MacOs => "bundle",
Os::Linux | Os::Windows | Os::Unknown => "so",
};
format!("{extension_name}.{extension}")
}
fn gem_build_command(gemspec_name: &str, output: &Path) -> Command {
let mut command = Command::new("ruby");
command.args(["-S", "gem", "build", gemspec_name, "--output"]);
command.arg(output);
command
}
fn run_gem_build(pkg_dir: &Path, gemspec_name: &str, gem_path: &Path) -> Result<()> {
let output = gem_build_command(gemspec_name, gem_path)
.current_dir(pkg_dir)
.output()
.context("failed to execute `ruby -S gem build`")?;
anyhow::ensure!(
output.status.success(),
"`ruby -S gem build {gemspec_name} --output {}` failed with {}: {}",
gem_path.display(),
output.status,
String::from_utf8_lossy(&output.stderr).trim()
);
Ok(())
}
fn find_ruby_native_lib(
workspace_root: &Path,
target: &RustTarget,
rb_crate: &str,
lib_filename: &str,
) -> Result<PathBuf> {
let in_crate_dir = workspace_root
.join("crates")
.join(rb_crate)
.join("target")
.join("release");
crate::publish::package::find_built_artifact_with_extra_dirs(
workspace_root,
target,
lib_filename,
BuildProfile::Release,
std::slice::from_ref(&in_crate_dir),
)
}
fn scan_rb_files(lib_dir: &Path) -> Result<Vec<PathBuf>> {
let mut found = Vec::new();
if !lib_dir.exists() {
return Ok(found);
}
for entry in fs::read_dir(lib_dir).with_context(|| format!("reading {}", lib_dir.display()))? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
for sub in fs::read_dir(&path).with_context(|| format!("reading {}", path.display()))? {
let sub = sub?;
let sub_path = sub.path();
if sub_path.extension().is_some_and(|e| e == "rb") {
found.push(sub_path);
}
}
} else if path.extension().is_some_and(|e| e == "rb") {
found.push(path);
}
}
Ok(found)
}
fn generate_platform_gemspec(
source_gemspec_name: &str,
version: &str,
platform: &str,
files: &[String],
) -> Result<String> {
let files_ruby = files
.iter()
.map(|f| format!(" {f:?}"))
.collect::<Vec<_>>()
.join(",\n");
Ok(format!(
r#"# frozen_string_literal: true
source_gemspec = File.expand_path({source_gemspec_name:?}, __dir__)
spec = Gem::Specification.load(source_gemspec)
raise "could not load source gemspec: #{{source_gemspec}}" unless spec
spec.version = {version:?}
spec.platform = {platform:?}
spec.files = (spec.files + [
{files_ruby}
]).uniq.sort
spec.extensions = []
spec
"#
))
}
fn ruby_abi_for_packaging() -> Result<String> {
if let Some(abi) = ruby_abi_override(std::env::var("RUBY_ABI").ok()) {
return Ok(abi);
}
let output = Command::new("ruby")
.arg("-rrbconfig")
.arg("-e")
.arg("print RbConfig::CONFIG.fetch(\"ruby_version\")")
.output()
.context("failed to execute `ruby` to read RbConfig['ruby_version']")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"`ruby -rrbconfig -e 'print RbConfig::CONFIG.fetch(\"ruby_version\")' failed with {}: {}",
output.status,
stderr.trim()
);
}
let abi = String::from_utf8(output.stdout)
.context("ruby output for RbConfig['ruby_version'] was not valid UTF-8")?
.trim()
.to_string();
if abi.is_empty() {
anyhow::bail!("ruby ABI is empty from `RbConfig['ruby_version']`")
}
Ok(abi)
}
fn ruby_abi_override(raw: Option<String>) -> Option<String> {
raw.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
fn write_executable(path: &Path, content: &str) {
use std::os::unix::fs::PermissionsExt as _;
std::fs::write(path, content).expect("write executable");
let mut permissions = std::fs::metadata(path).expect("metadata").permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(path, permissions).expect("chmod executable");
}
#[cfg(unix)]
#[test]
fn gem_build_uses_active_ruby_despite_foreign_gem_shebang() {
let temp = tempfile::tempdir().expect("tempdir");
let ruby = temp.path().join("ruby");
let gem = temp.path().join("gem");
let marker = temp.path().join("marker");
write_executable(
&ruby,
"#!/bin/sh\n[ \"$1\" = -S ] || exit 91\nshift\nscript=$(command -v \"$1\") || exit 92\nshift\nexec /bin/sh \"$script\" \"$@\"\n",
);
write_executable(
&gem,
"#!/missing/foreign/ruby\nprintf '%s\\n' \"$*\" > \"$ABI_PROBE\"\n",
);
let path = format!(
"{}:{}",
temp.path().display(),
std::env::var("PATH").unwrap_or_default()
);
let direct = Command::new(&gem).arg("build").arg("package.gemspec").status();
assert!(direct.is_err() || direct.is_ok_and(|status| !status.success()));
let status = gem_build_command("package.gemspec", Path::new("dist/package.gem"))
.env("PATH", path)
.env("ABI_PROBE", &marker)
.status()
.expect("run gem build command");
assert!(status.success());
assert_eq!(
std::fs::read_to_string(marker).expect("read marker"),
"build package.gemspec --output dist/package.gem\n"
);
}
#[test]
fn ruby_abi_override_trims_and_rejects_blank() {
assert_eq!(ruby_abi_override(Some("3.4.0".to_string())), Some("3.4.0".to_string()));
assert_eq!(
ruby_abi_override(Some(" 3.4.0 \n".to_string())),
Some("3.4.0".to_string())
);
assert_eq!(ruby_abi_override(Some(" ".to_string())), None);
assert_eq!(ruby_abi_override(Some(String::new())), None);
assert_eq!(ruby_abi_override(None), None);
}
#[test]
fn ruby_extension_filename_matches_ruby_loader_conventions() {
let cases = [
("x86_64-unknown-linux-gnu", "mylib.so"),
("aarch64-apple-darwin", "mylib.bundle"),
("x86_64-pc-windows-msvc", "mylib.so"),
];
for (triple, expected) in cases {
let target = RustTarget::parse(triple).expect("parse target");
assert_eq!(ruby_extension_filename(&target, "mylib"), expected);
}
}
#[test]
fn generate_platform_gemspec_reuses_source_metadata_and_replaces_build_fields() {
let files = vec![
"lib/mylib.rb".to_string(),
"lib/mylib/version.rb".to_string(),
"lib/mylib/native.rb".to_string(),
"lib/mylib/libmylib_rb.so".to_string(),
];
let spec = generate_platform_gemspec("mylib.gemspec", "1.0.0", "x86_64-linux", &files).unwrap();
assert!(
spec.contains(r#"Gem::Specification.load(source_gemspec)"#),
"source gemspec supplies authors, dependencies, licenses, and package metadata: {spec}"
);
assert!(spec.contains(r#"File.expand_path("mylib.gemspec", __dir__)"#));
assert!(spec.contains("1.0.0"), "version present");
assert!(spec.contains("x86_64-linux"), "platform present");
assert!(spec.contains("libmylib_rb.so"), "native lib present");
assert!(spec.contains("lib/mylib.rb"), "top-level wrapper present");
assert!(spec.contains("lib/mylib/version.rb"), "version wrapper present");
assert!(spec.contains("lib/mylib/native.rb"), "native wrapper present");
assert!(
spec.contains("spec.files + ["),
"all source package files remain in the platform gem: {spec}"
);
assert!(
spec.contains("spec.extensions = []"),
"precompiled gems must not invoke the source extension build: {spec}"
);
assert!(
!spec.contains("spec.authors =") && !spec.contains("spec.add_dependency"),
"metadata must have one implementation in the source gemspec: {spec}"
);
}
#[test]
#[ignore = "requires Ruby and RubyGems"]
fn generated_platform_gemspec_builds_with_source_metadata_and_native_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let lib_dir = tmp.path().join("lib/mylib/3.2.0");
let ext_dir = tmp.path().join("ext/mylib");
std::fs::create_dir_all(&lib_dir).expect("create lib directory");
std::fs::create_dir_all(&ext_dir).expect("create extension directory");
std::fs::write(tmp.path().join("README.md"), "# mylib\n").expect("write README");
std::fs::write(tmp.path().join("lib/mylib.rb"), "# frozen_string_literal: true\n").expect("write wrapper");
std::fs::write(lib_dir.join("libmylib_rb.so"), "native-placeholder").expect("write native placeholder");
std::fs::write(ext_dir.join("extconf.rb"), "# source build placeholder\n")
.expect("write extension placeholder");
std::fs::write(
tmp.path().join("mylib.gemspec"),
r#"Gem::Specification.new do |spec|
spec.name = "mylib"
spec.version = "0.1.0"
spec.authors = ["Alef Test"]
spec.summary = "metadata regression test"
spec.license = "MIT"
spec.required_ruby_version = ">= 3.2"
spec.files = ["README.md", "lib/mylib.rb"]
spec.require_paths = ["lib"]
spec.extensions = ["ext/mylib/extconf.rb"]
spec.add_dependency "rake", ">= 13"
end
"#,
)
.expect("write source gemspec");
let files = vec!["lib/mylib.rb".to_string(), "lib/mylib/3.2.0/libmylib_rb.so".to_string()];
let platform_gemspec = generate_platform_gemspec("mylib.gemspec", "1.2.3", "x86_64-linux", &files)
.expect("generate platform gemspec");
std::fs::write(tmp.path().join("mylib-platform.gemspec"), platform_gemspec).expect("write platform gemspec");
let output_dir = tmp.path().join("dist");
std::fs::create_dir(&output_dir).expect("create output directory");
let gem = output_dir.join("mylib-1.2.3-x86_64-linux.gem");
run_gem_build(tmp.path(), "mylib-platform.gemspec", &gem).expect("build platform gem");
assert!(gem.is_file(), "expected {}", gem.display());
assert!(
!tmp.path().join("mylib-1.2.3-x86_64-linux.gem").exists(),
"gem build must not leave a package-directory artifact"
);
let assertion = r#"
require "rubygems/package"
spec = Gem::Package.new(ARGV.fetch(0)).spec
abort "authors" unless spec.authors == ["Alef Test"]
abort "license" unless spec.licenses == ["MIT"]
abort "dependency" unless spec.runtime_dependencies.any? { |dep| dep.name == "rake" }
abort "ruby version" unless spec.required_ruby_version.satisfied_by?(Gem::Version.new("3.2"))
abort "extensions" unless spec.extensions.empty?
abort "README" unless spec.files.include?("README.md")
abort "native" unless spec.files.include?("lib/mylib/3.2.0/libmylib_rb.so")
"#;
let status = Command::new("ruby")
.args(["-e", assertion, gem.to_str().expect("UTF-8 gem path")])
.status()
.expect("inspect built gem");
assert!(status.success(), "built gem did not preserve source metadata");
}
#[test]
fn scan_rb_files_finds_wrappers_and_skips_non_rb() {
let tmp = tempfile::TempDir::new().unwrap();
let lib_dir = tmp.path().join("lib");
let sub_dir = lib_dir.join("mylib");
std::fs::create_dir_all(&sub_dir).unwrap();
std::fs::write(lib_dir.join("mylib.rb"), b"").unwrap();
std::fs::write(sub_dir.join("version.rb"), b"").unwrap();
std::fs::write(sub_dir.join("native.rb"), b"").unwrap();
std::fs::write(sub_dir.join("libmylib_rb.so"), b"").unwrap();
let mut found = scan_rb_files(&lib_dir).unwrap();
found.sort();
let names: Vec<String> = found
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert!(names.contains(&"mylib.rb".to_string()), "top-level wrapper found");
assert!(names.contains(&"version.rb".to_string()), "version.rb found");
assert!(names.contains(&"native.rb".to_string()), "native.rb found");
assert!(!names.contains(&"libmylib_rb.so".to_string()), ".so excluded from scan");
}
#[test]
fn scan_rb_files_errors_when_lib_dir_is_not_a_directory() {
let tmp = tempfile::TempDir::new().unwrap();
let lib = tmp.path().join("lib");
std::fs::write(&lib, b"not a directory").unwrap();
assert!(
scan_rb_files(&lib).is_err(),
"a non-directory lib path must be an error"
);
}
#[test]
fn find_ruby_native_lib_finds_canonical_workspace_uplift() {
let tmp = tempfile::TempDir::new().unwrap();
let target = RustTarget::parse("x86_64-unknown-linux-gnu").unwrap();
let release_dir = tmp.path().join("target").join(&target.triple).join("release");
std::fs::create_dir_all(&release_dir).unwrap();
std::fs::write(release_dir.join("libmylib_rb.so"), b"canonical").unwrap();
let found = find_ruby_native_lib(tmp.path(), &target, "mylib-rb", "libmylib_rb.so").unwrap();
assert_eq!(found, release_dir.join("libmylib_rb.so"));
}
#[test]
fn find_ruby_native_lib_falls_back_to_in_crate_build_dir() {
let tmp = tempfile::TempDir::new().unwrap();
let target = RustTarget::parse("x86_64-unknown-linux-gnu").unwrap();
let in_crate_dir = tmp.path().join("crates/mylib-rb/target/release");
std::fs::create_dir_all(&in_crate_dir).unwrap();
std::fs::write(in_crate_dir.join("libmylib_rb.so"), b"in-crate").unwrap();
let found = find_ruby_native_lib(tmp.path(), &target, "mylib-rb", "libmylib_rb.so").unwrap();
assert_eq!(
found,
in_crate_dir.join("libmylib_rb.so"),
"expected the in-crate fallback at {}, got {}",
in_crate_dir.join("libmylib_rb.so").display(),
found.display()
);
}
#[test]
fn find_ruby_native_lib_prefers_canonical_uplift_over_in_crate_fallback() {
let tmp = tempfile::TempDir::new().unwrap();
let target = RustTarget::parse("x86_64-unknown-linux-gnu").unwrap();
let release_dir = tmp.path().join("target").join(&target.triple).join("release");
std::fs::create_dir_all(&release_dir).unwrap();
std::fs::write(release_dir.join("libmylib_rb.so"), b"canonical").unwrap();
let in_crate_dir = tmp.path().join("crates/mylib-rb/target/release");
std::fs::create_dir_all(&in_crate_dir).unwrap();
std::fs::write(in_crate_dir.join("libmylib_rb.so"), b"in-crate").unwrap();
let found = find_ruby_native_lib(tmp.path(), &target, "mylib-rb", "libmylib_rb.so").unwrap();
assert_eq!(found, release_dir.join("libmylib_rb.so"));
}
}