use super::PackageArtifact;
use crate::core::config::ResolvedCrateConfig;
use crate::publish::platform::RustTarget;
use anyhow::{Context, Result};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
pub fn package_elixir(
config: &ResolvedCrateConfig,
target: &RustTarget,
workspace_root: &Path,
output_dir: &Path,
version: &str,
) -> Result<Vec<PackageArtifact>> {
let nif_versions = resolve_nif_versions(config);
let rustler_crate = crate::publish::crate_name_from_output(config, crate::core::config::extras::Language::Elixir)
.unwrap_or_else(|| config.elixir_app_name().to_lowercase().replace('-', "_") + "_rustler");
let lib_name = rustler_crate.replace('-', "_");
let shared_lib = target.shared_lib_name(&lib_name);
let lib_src = super::find_built_artifact(workspace_root, target, &shared_lib, super::BuildProfile::Release)?;
let ext = nif_extension(target);
let mut artifacts = Vec::new();
for nif_version in &nif_versions {
let tarball_name = format!(
"lib{lib_name}-v{version}-nif-{nif_version}-{triple}.{ext}.tar.gz",
triple = target.triple,
);
let tarball_path = output_dir.join(&tarball_name);
let stage_dir = output_dir.join(format!("_stage_{lib_name}_{nif_version}"));
if stage_dir.exists() {
fs::remove_dir_all(&stage_dir)?;
}
fs::create_dir_all(&stage_dir)?;
let staged_name = format!("lib{lib_name}.{ext}");
fs::copy(&lib_src, stage_dir.join(&staged_name))?;
super::create_tar_gz(&stage_dir, &tarball_path)
.with_context(|| format!("creating tarball {}", tarball_path.display()))?;
let _ = fs::remove_dir_all(&stage_dir);
artifacts.push(PackageArtifact {
path: tarball_path,
name: tarball_name,
checksum: None,
});
}
Ok(artifacts)
}
pub fn write_elixir_checksums(config: &ResolvedCrateConfig, output_dir: &Path) -> Result<PathBuf> {
let app_name = config.elixir_app_name();
let module_name = {
let mut chars = app_name.chars();
chars.next().map(|c| c.to_uppercase().to_string()).unwrap_or_default() + chars.as_str()
};
let mut checksums: BTreeMap<String, String> = BTreeMap::new();
for entry in fs::read_dir(output_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let name = path.file_name().unwrap_or_default().to_string_lossy();
if !name.ends_with(".tar.gz") || !name.contains("-nif-") {
continue;
}
let digest = sha256_file(&path)?;
checksums.insert(name.to_string(), format!("sha256:{digest}"));
}
let pkg_dir = config.package_dir(crate::core::config::extras::Language::Elixir);
let pkg_dir = Path::new(&pkg_dir);
let checksum_path = pkg_dir.join(format!("checksum-Elixir.{module_name}.Native.exs"));
let content = render_checksum_map(&checksums, formatter_line_length(pkg_dir));
fs::create_dir_all(checksum_path.parent().unwrap_or(Path::new(".")))?;
fs::write(&checksum_path, content)?;
Ok(checksum_path)
}
const ELIXIR_DEFAULT_LINE_LENGTH: usize = 98;
const MAP_ENTRY_INDENT: &str = " ";
const MAP_ARROW: &str = " => ";
fn formatter_line_length(package_dir: &Path) -> usize {
let Ok(source) = fs::read_to_string(package_dir.join(".formatter.exs")) else {
return ELIXIR_DEFAULT_LINE_LENGTH;
};
source
.lines()
.find_map(|line| {
let value = line.trim().strip_prefix("line_length:")?;
value.trim().trim_end_matches(',').parse::<usize>().ok()
})
.unwrap_or(ELIXIR_DEFAULT_LINE_LENGTH)
}
fn render_checksum_map(checksums: &BTreeMap<String, String>, line_length: usize) -> String {
#[derive(serde::Serialize)]
struct ChecksumEntry {
key: String,
value: String,
wrap: bool,
}
let last_index = checksums.len().saturating_sub(1);
let entries: Vec<ChecksumEntry> = checksums
.iter()
.enumerate()
.map(|(index, (file, digest))| {
let key = format!("\"{file}\"");
let value = format!("\"{digest}\"");
let trailing_comma = usize::from(index != last_index);
let one_line_width =
MAP_ENTRY_INDENT.len() + key.chars().count() + MAP_ARROW.len() + value.chars().count() + trailing_comma;
ChecksumEntry {
wrap: one_line_width > line_length,
key,
value,
}
})
.collect();
super::template_env::render("elixir_checksums.jinja", minijinja::context! { entries => entries })
}
fn nif_extension(target: &RustTarget) -> &'static str {
match target.os {
crate::publish::platform::Os::Windows => "dll",
_ => "so",
}
}
fn resolve_nif_versions(config: &ResolvedCrateConfig) -> Vec<String> {
if let Some(publish) = &config.publish
&& let Some(lang_cfg) = publish.languages.get("elixir")
&& let Some(versions) = &lang_cfg.nif_versions
&& !versions.is_empty()
{
return versions.clone();
}
vec!["2.16".to_string(), "2.17".to_string()]
}
fn sha256_file(path: &Path) -> Result<String> {
use sha2::{Digest, Sha256};
use std::io::Read;
let mut file = fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 65536];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
let digest = hasher.finalize();
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest.iter() {
use std::fmt::Write as _;
write!(&mut hex, "{byte:02x}").expect("writing to String never fails");
}
Ok(hex)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sha256_of(contents: &[u8]) -> String {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("payload.bin");
fs::write(&path, contents).unwrap();
sha256_file(&path).unwrap()
}
#[test]
fn sha256_known_vector() {
assert_eq!(
sha256_of(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn sha256_hello() {
assert_eq!(
sha256_of(b"hello"),
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
#[test]
fn sha256_spans_multiple_read_chunks() {
let payload: Vec<u8> = std::iter::repeat_n(b'a', 1_000_000).collect();
assert_eq!(
sha256_of(&payload),
"cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"
);
}
#[test]
fn nif_extension_linux() {
let t = RustTarget::parse("x86_64-unknown-linux-gnu").unwrap();
assert_eq!(nif_extension(&t), "so");
}
#[test]
fn nif_extension_macos() {
let t = RustTarget::parse("x86_64-apple-darwin").unwrap();
assert_eq!(nif_extension(&t), "so");
}
#[test]
fn nif_extension_windows() {
let t = RustTarget::parse("x86_64-pc-windows-msvc").unwrap();
assert_eq!(nif_extension(&t), "dll");
}
#[test]
fn resolve_nif_versions_defaults() {
let cfg: crate::core::config::NewAlefConfig = toml::from_str(
r#"
[workspace]
languages = ["elixir"]
[[crates]]
name = "my-lib"
sources = ["src/lib.rs"]
"#,
)
.unwrap();
let config = cfg.resolve().unwrap().remove(0);
let versions = resolve_nif_versions(&config);
assert!(!versions.is_empty());
}
#[test]
fn write_checksums_produces_exs_file() {
let tmp = TempDir::new().unwrap();
let cfg: crate::core::config::NewAlefConfig = toml::from_str(
r#"
[workspace]
languages = ["elixir"]
[[crates]]
name = "mylib"
sources = ["src/lib.rs"]
"#,
)
.unwrap();
let mut config = cfg.resolve().unwrap().remove(0);
config
.elixir
.get_or_insert_with(|| toml::from_str("").expect("an empty table deserializes to all-default ElixirConfig"))
.scaffold_output = Some(tmp.path().to_path_buf());
let tarball = tmp
.path()
.join("libmylib-v1.0.0-nif-2.16-x86_64-unknown-linux-gnu.so.tar.gz");
fs::write(&tarball, b"fake tarball content").unwrap();
let result = write_elixir_checksums(&config, tmp.path());
assert!(result.is_ok(), "{result:?}");
let checksum_file = result.unwrap();
assert!(checksum_file.exists());
let content = fs::read_to_string(&checksum_file).unwrap();
assert!(content.contains("sha256:"));
assert!(content.contains("nif-2.16"));
}
fn digest(fill: char) -> String {
format!("sha256:{}", std::iter::repeat_n(fill, 64).collect::<String>())
}
fn map_of(entries: &[(&str, String)]) -> BTreeMap<String, String> {
entries
.iter()
.map(|(file, digest)| ((*file).to_owned(), digest.clone()))
.collect()
}
fn mix_format(source: &str, line_length: usize) -> Option<String> {
let script = format!(
"IO.write(IO.iodata_to_binary(Code.format_string!(IO.read(:stdio, :eof), line_length: {line_length})) <> \"\\n\")"
);
let mut child = std::process::Command::new("elixir")
.args(["-e", &script])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.ok()?;
use std::io::Write as _;
child
.stdin
.take()
.expect("stdin was piped")
.write_all(source.as_bytes())
.expect("write source to elixir");
let out = child.wait_with_output().expect("wait for elixir");
assert!(
out.status.success(),
"elixir formatter failed: {}",
String::from_utf8_lossy(&out.stderr)
);
Some(String::from_utf8(out.stdout).expect("formatter output is UTF-8"))
}
#[test]
fn rendered_checksum_map_is_already_mix_format_stable() {
let checksums = map_of(&[
(
"libsample_rustler-v1.0.0-nif-2.16-aarch64-apple-darwin.so.tar.gz",
digest('a'),
),
(
"libsample_rustler-v1.0.0-nif-2.16-x86_64-unknown-linux-gnu.so.tar.gz",
digest('b'),
),
(
"libsample_rustler-v1.0.0-nif-2.17-x86_64-pc-windows-msvc.dll.tar.gz",
digest('c'),
),
("libs-v1.0.0-nif-2.16-x86_64-apple-darwin.so.tar.gz", digest('d')),
]);
for line_length in [98, 140] {
let rendered = render_checksum_map(&checksums, line_length);
let Some(formatted) = mix_format(&rendered, line_length) else {
tracing::warn!("elixir not on PATH, skipping formatter-stability check");
return;
};
assert_eq!(
rendered, formatted,
"emitted checksum map must be a fixed point of mix format at line_length {line_length}"
);
}
}
#[test]
fn entry_wraps_only_when_rendered_line_exceeds_line_length() {
let line_length = 98;
let at_limit = "f".repeat(line_length - 79 - 2);
let over_limit = "f".repeat(line_length - 79 - 1);
let last_at_limit = render_checksum_map(&map_of(&[(&at_limit, digest('a'))]), line_length);
assert!(
!last_at_limit.contains("=>\n"),
"final entry at exactly the limit stays inline: {last_at_limit}"
);
let last_over_limit = render_checksum_map(&map_of(&[(&over_limit, digest('a'))]), line_length);
assert!(
last_over_limit.contains("=>\n"),
"final entry one column over the limit wraps: {last_over_limit}"
);
let with_comma = render_checksum_map(
&map_of(&[(&at_limit, digest('a')), ("z.tar.gz", digest('b'))]),
line_length,
);
assert!(
with_comma.starts_with(&format!("%{{\n \"{at_limit}\" =>\n")),
"trailing comma counts toward the width: {with_comma}"
);
}
#[test]
fn empty_checksum_map_renders_as_collapsed_literal() {
assert_eq!(render_checksum_map(&BTreeMap::new(), 98), "%{}\n");
}
#[test]
fn formatter_line_length_reads_scaffolded_formatter_exs() {
let tmp = TempDir::new().unwrap();
assert_eq!(formatter_line_length(tmp.path()), ELIXIR_DEFAULT_LINE_LENGTH);
fs::write(
tmp.path().join(".formatter.exs"),
"[\n import_deps: [:rustler],\n inputs: [\"{mix,.formatter}.exs\"],\n line_length: 140\n]\n",
)
.unwrap();
assert_eq!(formatter_line_length(tmp.path()), 140);
}
}