use crate::cli::pipeline::helpers::{
check_precondition, precondition_passes, run_before, run_command, run_command_captured,
};
use crate::cli::registry;
use crate::core::config::{BuildCommandConfig, Language, ResolvedCrateConfig};
use crate::core::template_versions as tv;
use anyhow::Context as _;
use rayon::prelude::*;
use std::path::Path;
use tracing::{debug, info, warn};
mod frb_cache;
mod observability;
pub fn build(config: &ResolvedCrateConfig, languages: &[Language], release: bool) -> anyhow::Result<()> {
let crate_name = &config.name;
let base_dir = std::env::current_dir()?;
let mut independent = Vec::new();
let mut ffi_dependent = Vec::new();
let mut need_ffi = false;
let mut rust_langs: Vec<Language> = Vec::new();
let total_announced = languages.len();
let mut skipped_count = 0_usize;
let mut unmet: Vec<String> = Vec::new();
for &lang in languages {
let build_cmd_cfg = config.build_command_config_for_language(lang);
match backend_readiness(lang, &build_cmd_cfg) {
BackendReadiness::Ready => {}
BackendReadiness::ToolchainMissing => {
observability::skipped(lang, "required tool is not on PATH");
skipped_count += 1;
continue;
}
BackendReadiness::DependenciesUnfetched { check, remediation } => {
observability::unmet_precondition(
lang,
&format!("dependency precondition failed ({check})"),
&remediation,
);
unmet.push(format!("{lang} (run `{remediation}`)"));
continue;
}
}
if lang == Language::Rust {
rust_langs.push(lang);
continue;
}
let Some(backend) = registry::try_get_backend(lang) else {
info!("No binding backend for {lang}, skipping");
observability::skipped(lang, "no binding backend");
skipped_count += 1;
continue;
};
if let Some(bc) = backend.build_config_with_config(config) {
if bc.depends_on_ffi() {
ffi_dependent.push((lang, bc));
need_ffi = true;
} else {
independent.push((lang, bc));
}
} else {
info!("No build config for {lang}, skipping");
observability::skipped(lang, "no build config");
skipped_count += 1;
}
}
let dispatched_count = rust_langs.len() + independent.len() + ffi_dependent.len();
let mut failures: Vec<String> = Vec::new();
for &lang in &rust_langs {
let result = observability::observe(lang, || {
let build_cmd_cfg = config.build_command_config_for_language(lang);
run_before(lang, build_cmd_cfg.before.as_ref())?;
let cmds = if release {
build_cmd_cfg.build_release.as_ref()
} else {
build_cmd_cfg.build.as_ref()
};
if let Some(cmd_list) = cmds {
for cmd in cmd_list.commands() {
info!("Building {lang}: {cmd}");
run_command(cmd).with_context(|| format!("failed to build {lang}"))?;
}
}
Ok(())
});
if let Err(err) = result {
failures.push(format!("{lang}: {err:#}"));
}
}
if need_ffi
&& !independent
.iter()
.any(|(_, bc)| bc.tool == "cargo" && bc.crate_suffix == "-ffi")
{
let ffi_crate = output_path_for(Language::Ffi, config)
.map(resolve_crate_dir)
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or_else(|| Box::leak(format!("{crate_name}-ffi").into_boxed_str()));
info!("Building FFI crate: {ffi_crate}");
let mut cmd = format!("cargo build -p {ffi_crate}");
if release {
cmd.push_str(" --release");
}
let result = observability::observe(Language::Ffi, || run_command(&cmd).context("failed to build FFI crate"));
if let Err(err) = result {
failures.push(format!("{}: {err:#}", Language::Ffi));
}
}
let mut independent_ready = Vec::with_capacity(independent.len());
for (lang, bc) in independent {
let build_cmd_cfg = config.build_command_config_for_language(lang);
let before = build_cmd_cfg.before;
let before_result = if before.is_some() {
observability::observe(lang, || run_before(lang, before.as_ref()))
} else {
Ok(())
};
match before_result {
Ok(()) => independent_ready.push((lang, bc)),
Err(err) => failures.push(format!("{lang}: {err:#}")),
}
}
let independent = independent_ready;
let build_results: Vec<anyhow::Result<(String, String)>> = independent
.par_iter()
.map(|(lang, bc)| {
observability::observe(*lang, || {
let build_cmd_cfg = config.build_command_config_for_language(*lang);
let override_cmds = if release {
build_cmd_cfg.build_release.as_ref()
} else {
build_cmd_cfg.build.as_ref()
};
if let Some(cmd_list) = override_cmds
&& config.build_commands.contains_key(&lang.to_string())
{
let mut combined_output = (String::new(), String::new());
for cmd in cmd_list.commands() {
info!("Building {lang}: {cmd}");
let (stdout, stderr) = run_command_captured(cmd)
.with_context(|| format!("failed to build language bindings for {lang}"))?;
combined_output.0.push_str(&stdout);
combined_output.1.push_str(&stderr);
}
return Ok(combined_output);
}
info!("Building {lang} ({})...", bc.tool);
let build_cmd = build_command_for(*lang, bc, config, release);
run_command_captured(&build_cmd)
.with_context(|| format!("failed to build language bindings for {lang}"))
})
})
.collect();
for ((lang, bc), result) in independent.iter().zip(build_results) {
match result {
Ok((stdout, stderr)) => {
if !stdout.is_empty() {
info!("[{lang} build] {stdout}");
}
if !stderr.is_empty() {
debug!("[{lang} build] {stderr}");
}
if let Err(err) = run_post_build(*lang, bc, config, &base_dir) {
failures.push(format!("{lang}: post-build failed: {err:#}"));
}
}
Err(err) => failures.push(format!("{lang}: {err:#}")),
}
}
let mut ffi_dependent_ready = Vec::with_capacity(ffi_dependent.len());
for (lang, bc) in ffi_dependent {
let build_cmd_cfg = config.build_command_config_for_language(lang);
let before = build_cmd_cfg.before;
let before_result = if before.is_some() {
observability::observe(lang, || run_before(lang, before.as_ref()))
} else {
Ok(())
};
match before_result {
Ok(()) => ffi_dependent_ready.push((lang, bc)),
Err(err) => failures.push(format!("{lang}: {err:#}")),
}
}
let ffi_dependent = ffi_dependent_ready;
let build_results: Vec<anyhow::Result<(String, String)>> = ffi_dependent
.par_iter()
.map(|(lang, bc)| {
observability::observe(*lang, || {
let build_cmd_cfg = config.build_command_config_for_language(*lang);
let override_cmds = if release {
build_cmd_cfg.build_release.as_ref()
} else {
build_cmd_cfg.build.as_ref()
};
if let Some(cmd_list) = override_cmds
&& config.build_commands.contains_key(&lang.to_string())
{
let mut combined_output = (String::new(), String::new());
for cmd in cmd_list.commands() {
info!("Building {lang}: {cmd}");
let (stdout, stderr) = run_command_captured(cmd)
.with_context(|| format!("failed to build language bindings for {lang}"))?;
combined_output.0.push_str(&stdout);
combined_output.1.push_str(&stderr);
}
return Ok(combined_output);
}
info!("Building {lang} ({})...", bc.tool);
let build_cmd = build_command_for(*lang, bc, config, release);
run_command_captured(&build_cmd)
.with_context(|| format!("failed to build language bindings for {lang}"))
})
})
.collect();
for ((lang, bc), result) in ffi_dependent.iter().zip(build_results) {
match result {
Ok((stdout, stderr)) => {
if !stdout.is_empty() {
info!("[{lang} build] {stdout}");
}
if !stderr.is_empty() {
debug!("[{lang} build] {stderr}");
}
if let Err(err) = run_post_build(*lang, bc, config, &base_dir) {
failures.push(format!("{lang}: post-build failed: {err:#}"));
}
}
Err(err) => failures.push(format!("{lang}: {err:#}")),
}
}
debug_assert_eq!(
skipped_count + unmet.len() + dispatched_count,
total_announced,
"every announced language must be skipped, blocked on a precondition, or dispatched"
);
info!(
"Backend build summary: {total_announced} announced, {skipped_count} skipped, \
{} blocked on unmet preconditions, {dispatched_count} dispatched, {} language-level failure(s)",
unmet.len(),
failures.len()
);
build_outcome(&failures, &unmet)
}
fn build_outcome(failures: &[String], unmet: &[String]) -> anyhow::Result<()> {
let mut parts = Vec::new();
if !failures.is_empty() {
parts.push(format!(
"backend build failed for {} language(s): {}",
failures.len(),
failures.join("; ")
));
}
if !unmet.is_empty() {
parts.push(format!(
"{} language(s) were not built because their preconditions are unmet (no build was attempted, so this \
is not a compile failure): {}",
unmet.len(),
unmet.join("; ")
));
}
if parts.is_empty() {
return Ok(());
}
anyhow::bail!("{}", parts.join(" | "));
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum BackendReadiness {
Ready,
ToolchainMissing,
DependenciesUnfetched {
check: String,
remediation: String,
},
}
fn backend_readiness(lang: Language, build_cmd_cfg: &BuildCommandConfig) -> BackendReadiness {
if !check_precondition(lang, build_cmd_cfg.precondition.as_deref()) {
return BackendReadiness::ToolchainMissing;
}
let Some(check) = build_cmd_cfg.dependency_precondition.as_deref() else {
return BackendReadiness::Ready;
};
if precondition_passes(&lang.to_string(), check) {
return BackendReadiness::Ready;
}
let remediation = build_cmd_cfg
.dependency_remediation
.clone()
.unwrap_or_else(|| format!("(no `dependency_remediation` declared for {lang})"));
BackendReadiness::DependenciesUnfetched {
check: check.to_string(),
remediation,
}
}
fn resolve_crate_dir(output_path: &Path) -> &Path {
if output_path.file_name().is_some_and(|n| n == "src") {
output_path.parent().unwrap_or(output_path)
} else {
output_path
}
}
fn output_path_for(lang: Language, config: &ResolvedCrateConfig) -> Option<&Path> {
match lang {
Language::Python => config.explicit_output.python.as_deref(),
Language::Node => config.explicit_output.node.as_deref(),
Language::Ruby => config.explicit_output.ruby.as_deref(),
Language::Php => config.explicit_output.php.as_deref(),
Language::Ffi => config.explicit_output.ffi.as_deref(),
Language::Go => config.explicit_output.go.as_deref(),
Language::Java => config.explicit_output.java.as_deref(),
Language::Csharp => config.explicit_output.csharp.as_deref(),
Language::Kotlin => config.explicit_output.kotlin.as_deref(),
Language::KotlinAndroid => config.explicit_output.kotlin_android.as_deref(),
Language::Wasm => config.explicit_output.wasm.as_deref(),
Language::Elixir => config.explicit_output.elixir.as_deref(),
Language::R => config.explicit_output.r.as_deref(),
Language::Rust | Language::C | Language::Jni => None,
Language::Swift | Language::Dart | Language::Gleam | Language::Zig => None,
}
}
fn build_command_for(
lang: Language,
bc: &crate::core::backend::BuildConfig,
config: &ResolvedCrateConfig,
release: bool,
) -> String {
let release_flag = if release { " --release" } else { "" };
let crate_dir = output_path_for(lang, config)
.map(resolve_crate_dir)
.and_then(|p| p.to_str())
.unwrap_or("");
match bc.tool {
"maturin" => {
format!("maturin develop --manifest-path {crate_dir}/Cargo.toml{release_flag}")
}
"napi" => {
format!(
"npx --yes -p @napi-rs/cli@{} napi build --platform --manifest-path {}/Cargo.toml -o {}{}",
tv::npm::NAPI_RS_CLI_CRATE,
crate_dir,
crate_dir,
release_flag
)
}
"wasm-pack" => {
let profile = if release { "--release" } else { "--dev" };
let wasm_crate_dir = if crate_dir.is_empty() {
config.package_dir(lang)
} else {
crate_dir.to_string()
};
let targets = config.wasm_targets();
if targets.is_empty() {
return format!(
"echo 'alef: [crates.wasm] targets is empty for {lang}; list at least one \
wasm-pack target (web, bundler, nodejs, deno)' >&2 && false"
);
}
let builds = targets
.iter()
.map(|target| format!("wasm-pack build {profile} --target {target} --out-dir pkg/{target}"))
.collect::<Vec<_>>()
.join(" && ");
format!("cd {wasm_crate_dir} && {builds}")
}
"cargo" => {
if crate_dir.is_empty() && !bc.crate_suffix.is_empty() {
return format!("cargo build -p {}{}{}", config.name, bc.crate_suffix, release_flag);
}
let native_dir = Path::new(crate_dir).join("native");
let native_manifest = native_dir.join("Cargo.toml");
if native_manifest.exists() {
let dir = native_dir.display();
format!("cd {dir} && cargo build{release_flag}")
} else if let Some(standalone) = {
let mut p = std::path::PathBuf::from(crate_dir);
let mut found: Option<std::path::PathBuf> = None;
for _ in 0..3 {
let manifest = p.join("Cargo.toml");
if manifest.exists() {
if let Ok(contents) = std::fs::read_to_string(&manifest)
&& contents.contains("[workspace]")
{
found = Some(p.clone());
}
break;
}
if !p.pop() {
break;
}
}
found
} {
let dir = standalone.display();
format!("cd {dir} && cargo build{release_flag}")
} else {
let mut p = std::path::PathBuf::from(crate_dir);
let mut package_name: Option<String> = None;
let mut package_dir: Option<std::path::PathBuf> = None;
for _ in 0..4 {
let manifest = p.join("Cargo.toml");
if manifest.exists() {
if let Ok(contents) = std::fs::read_to_string(&manifest)
&& contents.contains("[package]")
{
for line in contents.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("name") {
let rest = rest.trim_start_matches([' ', '=']).trim();
let rest = rest.trim_matches(['"', '\'']);
if !rest.is_empty() {
package_name = Some(rest.to_string());
package_dir = Some(p.clone());
break;
}
}
}
}
break;
}
if !p.pop() {
break;
}
}
let is_excluded_from_workspace = if let Some(pdir) = &package_dir {
let mut q = pdir.clone();
let mut excluded = false;
while q.pop() {
let manifest = q.join("Cargo.toml");
if manifest.exists()
&& let Ok(contents) = std::fs::read_to_string(&manifest)
&& contents.contains("[workspace]")
{
let rel = pdir.strip_prefix(&q).unwrap_or(pdir).to_string_lossy().into_owned();
let rel_norm = rel.replace('\\', "/");
excluded = contents.lines().map(|l| l.trim()).any(|l| {
l.contains(&format!("\"{rel_norm}\"")) && {
let needle = format!("\"{rel_norm}\"");
let exclude_section = contents.split("exclude").nth(1).unwrap_or("");
let members_section = contents.split("members").nth(1).unwrap_or("");
let in_exclude = exclude_section.contains(&needle);
let in_members =
members_section.contains(&needle) && !exclude_section.contains(&needle);
in_exclude && !in_members
}
});
break;
}
}
excluded
} else {
false
};
if is_excluded_from_workspace {
if let Some(pdir) = package_dir {
let dir = pdir.display();
format!("cd {dir} && cargo build{release_flag}")
} else {
format!("cd {crate_dir} && cargo build{release_flag}")
}
} else {
let crate_name = package_name.unwrap_or_else(|| {
Path::new(crate_dir)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(crate_dir)
.to_string()
});
format!("cargo build -p {crate_name}{release_flag}")
}
}
}
"mix" => {
let dir = config
.explicit_output
.elixir
.as_ref()
.and_then(|p| p.to_str())
.unwrap_or("packages/elixir");
let build_dir = {
let mut p = std::path::PathBuf::from(dir);
loop {
if p.join("mix.exs").exists() {
break p.to_string_lossy().into_owned();
}
if !p.pop() {
break dir.to_string();
}
}
};
format!("cd {build_dir} && mix compile")
}
"mvn" => {
let dir = config
.explicit_output
.java
.as_ref()
.and_then(|p| p.to_str())
.unwrap_or("packages/java");
let build_dir = {
let mut p = std::path::PathBuf::from(dir);
loop {
if p.join("pom.xml").exists() {
break p.to_string_lossy().into_owned();
}
if !p.pop() {
break dir.to_string();
}
}
};
format!("cd {build_dir} && mvn package -DskipTests --batch-mode --no-transfer-progress")
}
"dotnet" => {
let dir = config
.explicit_output
.csharp
.as_ref()
.and_then(|p| p.to_str())
.unwrap_or("packages/csharp");
let scan_for_csproj = |start: &std::path::Path| -> Option<String> {
if start
.read_dir()
.ok()
.map(|entries| {
entries
.filter_map(|e| e.ok())
.any(|e| e.path().extension().is_some_and(|ext| ext == "csproj"))
})
.unwrap_or(false)
{
return Some(start.to_string_lossy().to_string());
}
start.read_dir().ok().and_then(|entries| {
entries
.filter_map(|e| e.ok())
.find(|e| {
e.path().is_dir()
&& e.path().read_dir().ok().is_some_and(|sub| {
sub.filter_map(|s| s.ok())
.any(|s| s.path().extension().is_some_and(|ext| ext == "csproj"))
})
})
.map(|e| e.path().to_string_lossy().to_string())
})
};
let build_dir = {
let mut p = std::path::PathBuf::from(dir);
let mut found = scan_for_csproj(&p);
while found.is_none() && p.pop() {
found = scan_for_csproj(&p);
}
found.unwrap_or_else(|| dir.to_string())
};
let dotnet_config = if release { "Release" } else { "Debug" };
format!("cd {build_dir} && dotnet build --configuration {dotnet_config} --verbosity quiet")
}
"go" => {
let dir = config
.explicit_output
.go
.as_ref()
.and_then(|p| p.to_str())
.unwrap_or("packages/go");
format!("cd {dir} && go build ./...")
}
"gradle" => {
let release_property = if release { " -Prelease" } else { "" };
let source_dir = if crate_dir.is_empty() {
config.package_dir(lang)
} else {
crate_dir.to_string()
};
let find_marker_dir = |start: &str, markers: &[&str]| -> Option<String> {
let mut p = std::path::PathBuf::from(start);
loop {
if markers.iter().any(|marker| p.join(marker).exists()) {
return Some(p.to_string_lossy().into_owned());
}
if !p.pop() {
return None;
}
}
};
let build_dir = find_marker_dir(&source_dir, &["settings.gradle.kts", "settings.gradle"])
.or_else(|| find_marker_dir(&source_dir, &["build.gradle.kts", "build.gradle"]))
.unwrap_or(source_dir);
format!("cd {build_dir} && gradle build{release_property}")
}
"swift" => {
let package_dir = config.package_dir(lang);
let configuration = if release { " --configuration release" } else { "" };
format!("swift build --package-path {package_dir}{configuration}")
}
"zig" => {
let package_dir = config.package_dir(lang);
let release_flag = if release { " --release=fast" } else { "" };
format!("cd {package_dir} && zig build{release_flag}")
}
"gleam" => {
let package_dir = config.package_dir(lang);
format!("cd {package_dir} && gleam build")
}
_ => format!(
"echo 'alef: no default build command for tool \"{}\" (language: {lang}); add [crates.build_commands.{lang}] build = [...] to alef.toml' >&2 && false",
bc.tool
),
}
}
pub fn run_post_build(
lang: Language,
bc: &crate::core::backend::BuildConfig,
config: &ResolvedCrateConfig,
base_dir: &Path,
) -> anyhow::Result<()> {
use crate::core::backend::PostBuildStep;
let crate_dir = output_path_for(lang, config)
.map(resolve_crate_dir)
.unwrap_or(Path::new(""));
for step in &bc.post_build {
match step {
PostBuildStep::PatchFile { path, find, replace } => {
let file_path = base_dir.join(crate_dir).join(path);
if file_path.exists() {
let content = std::fs::read_to_string(&file_path)
.with_context(|| format!("failed to read post-build patch target {}", file_path.display()))?;
if content.contains(replace) {
debug!("Post-build patch target already patched: {}", file_path.display());
continue;
}
let patched = content.replace(find, replace);
if patched != content {
std::fs::write(&file_path, &patched)
.with_context(|| format!("failed to write patched file {}", file_path.display()))?;
info!("Patched {}: replaced '{}' → '{}'", file_path.display(), find, replace);
}
} else {
debug!("Post-build patch target not found: {}", file_path.display());
}
}
PostBuildStep::RunCommand { cmd, args } => {
let work_dir = base_dir.join(crate_dir);
run_run_command(cmd, args, &work_dir, &config.name)
.with_context(|| format!("post-build RunCommand '{cmd}' failed"))?;
}
PostBuildStep::PostProcessFile { path, processor } => {
use crate::core::backend::PostProcessor;
let file_path = base_dir.join(crate_dir).join(path);
if file_path.exists() {
let content = std::fs::read_to_string(&file_path)
.with_context(|| format!("failed to read post-process target {}", file_path.display()))?;
let processed = match processor {
PostProcessor::FrbDartSealedVariants => {
crate::backends::dart::rewrite_frb_sealed_variants(&content, &config.dart_pubspec_name())
}
PostProcessor::FrbDartExcludeFunctions(excluded) => {
let exclude_set: std::collections::HashSet<&str> =
excluded.iter().map(|s| s.as_str()).collect();
crate::backends::dart::filter_excluded_functions(&content, &exclude_set)
}
PostProcessor::FrbDartOptionalFieldsWithDefaults => {
crate::backends::dart::make_struct_fields_with_defaults_optional(&content)
}
PostProcessor::FrbDartFixHandlerExecutorCalls => {
crate::backends::dart::fix_handler_executor_calls(&content)
}
PostProcessor::FrbDartInjectTextMethods(type_names) => {
crate::backends::dart::inject_display_as_text_methods(&content, type_names)
}
PostProcessor::DartStripTrailingWhitespace => {
crate::backends::dart::strip_trailing_whitespace(&content)
}
};
if processed != content {
std::fs::write(&file_path, &processed)
.with_context(|| format!("failed to write post-processed file {}", file_path.display()))?;
info!("PostProcessed {}: {:?}", file_path.display(), processor);
} else {
debug!(
"PostProcessFile {}: no changes (already rewritten or absent variants)",
file_path.display()
);
}
} else {
debug!("PostProcessFile target not found: {}", file_path.display());
}
}
PostBuildStep::CarryFrbCfgGates {
source_path,
target_path,
} => {
let source_file = base_dir.join(crate_dir).join(source_path);
let target_file = base_dir.join(crate_dir).join(target_path);
if source_file.exists() && target_file.exists() {
let source_content = std::fs::read_to_string(&source_file)
.with_context(|| format!("failed to read cfg-gate source {}", source_file.display()))?;
let target_content = std::fs::read_to_string(&target_file)
.with_context(|| format!("failed to read cfg-gate target {}", target_file.display()))?;
let rewritten = crate::backends::dart::carry_lib_rs_cfg_gates_into_frb_generated(
&source_content,
&target_content,
);
if rewritten != target_content {
std::fs::write(&target_file, &rewritten)
.with_context(|| format!("failed to write cfg-gated file {}", target_file.display()))?;
info!(
"Carried #[cfg] gates from {} into {}",
source_file.display(),
target_file.display()
);
} else {
debug!("CarryFrbCfgGates {}: no changes needed", target_file.display());
}
} else {
debug!(
"CarryFrbCfgGates source or target not found: {} / {}",
source_file.display(),
target_file.display()
);
}
}
PostBuildStep::StageDartNatives { lib_stem } => {
let package_root = base_dir.join("packages/dart");
let status =
crate::publish::dart_native::stage_dart_native_libraries(base_dir, &package_root, lib_stem)
.with_context(|| format!("failed to stage Dart native libraries for stem '{lib_stem}'"))?;
match status {
crate::publish::dart_native::NativeLibraryStageStatus::Staged => {
info!("Staged native libraries for Dart package from build output (stem: '{lib_stem}')");
}
crate::publish::dart_native::NativeLibraryStageStatus::Missing => {
debug!("No Dart native libraries available to stage for development stem '{lib_stem}'");
}
}
}
PostBuildStep::MaterializeSwiftBridge {
binding_crate_name,
package_root,
} => {
let package_root = base_dir.join(package_root);
let materialized = crate::backends::swift::gen_bindings::bridge_artifacts::emit_swift_bridge_files(
"",
binding_crate_name,
&package_root,
)
.with_context(|| format!("failed to re-materialize swift-bridge files for '{binding_crate_name}'"))?;
if let Some(files) = materialized {
for f in files {
if let Some(parent) = f.path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
std::fs::write(&f.path, &f.content)
.with_context(|| format!("failed to write {}", f.path.display()))?;
}
}
info!("Re-materialized swift-bridge files for '{binding_crate_name}' from fresh build output");
}
PostBuildStep::RewriteWasmPackageName {
package_json_path,
package_name,
} => {
let file_path = base_dir.join(package_json_path);
if file_path.exists() {
rewrite_wasm_package_json_name(&file_path, package_name)
.with_context(|| format!("failed to rewrite wasm package name in {}", file_path.display()))?;
} else {
debug!(
"wasm-pack package.json not found for name rewrite: {}",
file_path.display()
);
}
}
}
}
Ok(())
}
fn rewrite_wasm_package_json_name(path: &Path, new_name: &str) -> anyhow::Result<()> {
let content = std::fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
let name_field = regex::Regex::new(r#""name"\s*:\s*"[^"]*""#).expect("static regex is valid");
let escaped_name = new_name.replace('\\', "\\\\").replace('"', "\\\"");
let replacement = format!("\"name\": \"{escaped_name}\"");
let rewritten = name_field.replacen(&content, 1, replacement.as_str());
if rewritten != content {
std::fs::write(path, rewritten.as_ref()).with_context(|| format!("failed to write {}", path.display()))?;
info!("Rewrote wasm package name in {} to '{new_name}'", path.display());
} else {
debug!(
"wasm package.json {}: name already '{new_name}' or no name field found",
path.display()
);
}
Ok(())
}
const RUN_COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1800);
const RUN_COMMAND_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(200);
fn run_run_command(cmd: &str, args: &[&str], base_dir: &Path, cache_scope: &str) -> anyhow::Result<()> {
if let Ok(skip_list) = std::env::var("ALEF_SKIP_COMMANDS")
&& skip_list.split(',').any(|s| s.trim() == cmd)
{
warn!("[{cmd}] skipped via ALEF_SKIP_COMMANDS env var");
return Ok(());
}
let mut command = std::process::Command::new(cmd);
command
.args(args)
.current_dir(base_dir)
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
frb_cache::configure(&mut command, cmd, cache_scope)?;
let mut child = match command.spawn() {
Ok(child) => child,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
warn!(
"[{cmd}] not on PATH — skipping post-build step. Install '{cmd}' to regenerate at build time; falling back to committed generated files."
);
return Ok(());
}
Err(err) => return Err(anyhow::Error::new(err).context(format!("failed to spawn '{cmd}'"))),
};
let started_at = std::time::Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {
if started_at.elapsed() > RUN_COMMAND_TIMEOUT {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!("'{cmd}' exceeded {}s timeout; killed", RUN_COMMAND_TIMEOUT.as_secs());
}
std::thread::sleep(RUN_COMMAND_POLL_INTERVAL);
}
Err(err) => {
return Err(anyhow::Error::new(err).context(format!("failed to wait for '{cmd}'")));
}
}
};
if !status.success() {
let code = status.code().unwrap_or(-1);
anyhow::bail!("'{cmd}' exited with status {code}");
}
Ok(())
}
#[cfg(test)]
mod readiness_tests {
use super::*;
fn cfg(precondition: &str, dependency_precondition: Option<&str>) -> BuildCommandConfig {
BuildCommandConfig {
precondition: Some(precondition.to_string()),
dependency_precondition: dependency_precondition.map(str::to_string),
dependency_remediation: dependency_precondition.map(|_| "cd packages/elixir && mix deps.get".to_string()),
before: None,
build: None,
build_release: None,
}
}
#[test]
fn should_report_unfetched_dependencies_when_the_tool_is_present_but_deps_are_not() {
let readiness = backend_readiness(Language::Elixir, &cfg("true", Some("false")));
assert_eq!(
readiness,
BackendReadiness::DependenciesUnfetched {
check: "false".to_string(),
remediation: "cd packages/elixir && mix deps.get".to_string(),
}
);
}
#[test]
fn should_stay_ready_when_every_precondition_passes_so_a_real_compile_failure_still_fails() {
assert_eq!(
backend_readiness(Language::Elixir, &cfg("true", Some("true"))),
BackendReadiness::Ready
);
assert_eq!(
backend_readiness(Language::Go, &cfg("true", None)),
BackendReadiness::Ready
);
let error = build_outcome(&["go: undefined: Foo".to_string()], &[]).expect_err("compile failure is fatal");
let message = error.to_string();
assert!(message.contains("backend build failed for 1 language(s)"), "{message}");
assert!(!message.contains("preconditions are unmet"), "{message}");
}
#[test]
fn should_report_a_missing_tool_as_a_toolchain_skip_not_as_unfetched_dependencies() {
let readiness = backend_readiness(Language::Elixir, &cfg("false", Some("false")));
assert_eq!(readiness, BackendReadiness::ToolchainMissing);
}
#[test]
fn should_exit_clean_when_the_only_thing_that_happened_was_a_toolchain_skip() {
assert!(build_outcome(&[], &[]).is_ok());
}
#[test]
fn should_fail_the_run_for_unmet_preconditions_while_naming_them_separately_from_failures() {
let error = build_outcome(&[], &["elixir (run `cd packages/elixir && mix deps.get`)".to_string()])
.expect_err("unmet preconditions must not exit clean");
let message = error.to_string();
assert!(message.contains("1 language(s) were not built"), "{message}");
assert!(message.contains("not a compile failure"), "{message}");
assert!(message.contains("mix deps.get"), "{message}");
assert!(!message.contains("backend build failed"), "{message}");
}
#[test]
fn should_keep_failure_and_unmet_counts_separate_when_both_occur() {
let error = build_outcome(
&["go: undefined: Foo".to_string()],
&["elixir (run `mix deps.get`)".to_string()],
)
.expect_err("either bucket is fatal");
let message = error.to_string();
assert!(message.contains("backend build failed for 1 language(s)"), "{message}");
assert!(message.contains("1 language(s) were not built"), "{message}");
}
}
#[cfg(test)]
mod build_command_tests {
use super::*;
use crate::core::backend::{BuildConfig, BuildDependency};
#[test]
fn csharp_build_command_uses_verbosity_flag_not_query_mode() {
let alef_cfg: crate::core::config::NewAlefConfig = toml::from_str(
r#"
[workspace]
languages = ["csharp"]
[[crates]]
name = "test-lib"
sources = ["src/lib.rs"]
"#,
)
.unwrap();
let config = alef_cfg.resolve().unwrap().remove(0);
let build_config = BuildConfig {
tool: "dotnet",
crate_suffix: "",
build_dep: BuildDependency::Ffi,
post_build: Vec::new(),
};
let command = build_command_for(Language::Csharp, &build_config, &config, false);
assert!(
command.contains("--verbosity quiet"),
"C# build must use explicit quiet verbosity: {command}"
);
assert!(
!command.contains(" -q"),
"C# build must not use dotnet query mode shorthand: {command}"
);
}
#[test]
fn kotlin_gradle_build_command_runs_in_generated_package() {
let alef_cfg: crate::core::config::NewAlefConfig = toml::from_str(
r#"
[workspace]
languages = ["kotlin"]
[[crates]]
name = "sample-lib"
sources = ["src/lib.rs"]
"#,
)
.unwrap();
let config = alef_cfg.resolve().unwrap().remove(0);
let build_config = BuildConfig {
tool: "gradle",
crate_suffix: "",
build_dep: BuildDependency::Ffi,
post_build: Vec::new(),
};
assert_eq!(
build_command_for(Language::Kotlin, &build_config, &config, false),
"cd packages/kotlin && gradle build"
);
assert_eq!(
build_command_for(Language::Kotlin, &build_config, &config, true),
"cd packages/kotlin && gradle build -Prelease"
);
}
#[test]
fn gradle_build_walks_up_to_settings_gradle_root_from_deep_namespace_dir() {
let root = tempfile::tempdir().expect("failed to create temp dir");
let project_root = root.path().join("packages/kotlin-android");
let deep_source_dir = project_root.join("src/main/kotlin/io/alpha/mylib/android");
std::fs::create_dir_all(&deep_source_dir).expect("failed to create deep namespace dir");
std::fs::write(
project_root.join("settings.gradle.kts"),
"rootProject.name = \"alpha\"\n",
)
.expect("failed to write settings.gradle.kts fixture");
std::fs::write(project_root.join("build.gradle.kts"), "// android library build\n")
.expect("failed to write build.gradle.kts fixture");
assert!(project_root.join("settings.gradle.kts").is_file());
assert!(project_root.join("build.gradle.kts").is_file());
assert!(!deep_source_dir.join("settings.gradle.kts").exists());
assert!(!deep_source_dir.join("build.gradle.kts").exists());
let alef_cfg: crate::core::config::NewAlefConfig = toml::from_str(&format!(
r#"
[workspace]
languages = ["kotlin_android"]
[[crates]]
name = "sample-lib"
sources = ["src/lib.rs"]
[crates.kotlin_android]
package = "dev.alpha"
[crates.output]
kotlin_android = "{deep_source_dir}/"
"#,
deep_source_dir = deep_source_dir.display(),
))
.unwrap();
let config = alef_cfg.resolve().unwrap().remove(0);
let build_config = BuildConfig {
tool: "gradle",
crate_suffix: "",
build_dep: BuildDependency::Ffi,
post_build: Vec::new(),
};
let expected_root = project_root.display().to_string();
assert_eq!(
build_command_for(Language::KotlinAndroid, &build_config, &config, false),
format!("cd {expected_root} && gradle build")
);
assert_eq!(
build_command_for(Language::KotlinAndroid, &build_config, &config, true),
format!("cd {expected_root} && gradle build -Prelease")
);
}
#[test]
fn gradle_build_falls_back_to_source_dir_when_no_marker_found() {
let root = tempfile::tempdir().expect("failed to create temp dir");
let deep_source_dir = root
.path()
.join("packages/kotlin-android/src/main/kotlin/io/alpha/mylib/android");
std::fs::create_dir_all(&deep_source_dir).expect("failed to create deep namespace dir");
assert!(deep_source_dir.is_dir());
for ancestor in deep_source_dir.ancestors() {
for marker in [
"settings.gradle.kts",
"settings.gradle",
"build.gradle.kts",
"build.gradle",
] {
assert!(
!ancestor.join(marker).exists(),
"fixture must not contain {marker} under {}",
ancestor.display()
);
}
if ancestor == root.path() {
break;
}
}
let alef_cfg: crate::core::config::NewAlefConfig = toml::from_str(&format!(
r#"
[workspace]
languages = ["kotlin_android"]
[[crates]]
name = "sample-lib"
sources = ["src/lib.rs"]
[crates.kotlin_android]
package = "dev.alpha"
[crates.output]
kotlin_android = "{deep_source_dir}/"
"#,
deep_source_dir = deep_source_dir.display(),
))
.unwrap();
let config = alef_cfg.resolve().unwrap().remove(0);
let build_config = BuildConfig {
tool: "gradle",
crate_suffix: "",
build_dep: BuildDependency::Ffi,
post_build: Vec::new(),
};
let expected = deep_source_dir.display().to_string();
assert_eq!(
build_command_for(Language::KotlinAndroid, &build_config, &config, false),
format!("cd {expected}/ && gradle build")
);
}
#[test]
fn unknown_build_tool_fails_instead_of_reporting_success() {
let config = ResolvedCrateConfig::default();
let build_config = BuildConfig {
tool: "unsupported",
crate_suffix: "",
build_dep: BuildDependency::None,
post_build: Vec::new(),
};
let command = build_command_for(Language::Kotlin, &build_config, &config, false);
assert!(
command.ends_with("&& false"),
"unknown build tool must still exit non-zero: {command}"
);
assert!(
command.contains("no default build command for tool \"unsupported\""),
"unknown build tool failure must name the missing tool instead of a bare `false`: {command}"
);
}
fn wasm_config(extra: &str) -> ResolvedCrateConfig {
let alef_cfg: crate::core::config::NewAlefConfig = toml::from_str(&format!(
r#"
[workspace]
languages = ["wasm"]
[[crates]]
name = "sample-lib"
sources = ["src/lib.rs"]
{extra}
"#
))
.unwrap();
alef_cfg.resolve().unwrap().remove(0)
}
fn wasm_build_config() -> BuildConfig {
BuildConfig {
tool: "wasm-pack",
crate_suffix: "-wasm",
build_dep: BuildDependency::None,
post_build: Vec::new(),
}
}
#[test]
fn wasm_build_command_builds_every_default_target_into_its_own_pkg_subdir() {
let config = wasm_config("");
let command = build_command_for(Language::Wasm, &wasm_build_config(), &config, false);
assert_eq!(
command,
"cd crates/sample-lib-wasm && \
wasm-pack build --dev --target web --out-dir pkg/web && \
wasm-pack build --dev --target bundler --out-dir pkg/bundler && \
wasm-pack build --dev --target nodejs --out-dir pkg/nodejs && \
wasm-pack build --dev --target deno --out-dir pkg/deno"
);
}
#[test]
fn wasm_build_command_never_leaves_a_target_in_the_bare_pkg_dir() {
let command = build_command_for(Language::Wasm, &wasm_build_config(), &wasm_config(""), true);
assert!(
!command.contains("--target web --out-dir pkg "),
"no target may use wasm-pack's default bare `pkg/` out-dir: {command}"
);
for target in ["web", "bundler", "nodejs", "deno"] {
assert!(
command.contains(&format!("--target {target} --out-dir pkg/{target}")),
"release build must still pair every target with its own out-dir: {command}"
);
}
assert!(command.contains("--release"), "{command}");
assert!(!command.contains("--dev"), "{command}");
}
#[test]
fn wasm_build_command_honours_a_narrowed_target_set() {
let config = wasm_config("\n[crates.wasm]\ntargets = [\"web\"]\n");
let command = build_command_for(Language::Wasm, &wasm_build_config(), &config, false);
assert_eq!(
command,
"cd crates/sample-lib-wasm && wasm-pack build --dev --target web --out-dir pkg/web"
);
assert!(!command.contains("nodejs"), "{command}");
}
#[test]
fn wasm_build_command_fails_loudly_on_an_empty_target_set() {
let config = wasm_config("\n[crates.wasm]\ntargets = []\n");
let command = build_command_for(Language::Wasm, &wasm_build_config(), &config, false);
assert!(command.ends_with("&& false"), "must exit non-zero: {command}");
assert!(command.contains("[crates.wasm] targets is empty"), "{command}");
assert!(
!command.contains("wasm-pack build"),
"must not emit a truncated wasm-pack invocation: {command}"
);
}
#[test]
fn swift_build_command_uses_swift_build_with_package_path() {
let alef_cfg: crate::core::config::NewAlefConfig = toml::from_str(
r#"
[workspace]
languages = ["swift"]
[[crates]]
name = "sample-lib"
sources = ["src/lib.rs"]
"#,
)
.unwrap();
let config = alef_cfg.resolve().unwrap().remove(0);
let build_config = BuildConfig {
tool: "swift",
crate_suffix: "-swift",
build_dep: BuildDependency::None,
post_build: Vec::new(),
};
assert_eq!(
build_command_for(Language::Swift, &build_config, &config, false),
"swift build --package-path packages/swift"
);
assert_eq!(
build_command_for(Language::Swift, &build_config, &config, true),
"swift build --package-path packages/swift --configuration release"
);
}
#[test]
fn zig_build_command_uses_zig_build() {
let alef_cfg: crate::core::config::NewAlefConfig = toml::from_str(
r#"
[workspace]
languages = ["zig"]
[[crates]]
name = "sample-lib"
sources = ["src/lib.rs"]
"#,
)
.unwrap();
let config = alef_cfg.resolve().unwrap().remove(0);
let build_config = BuildConfig {
tool: "zig",
crate_suffix: "",
build_dep: BuildDependency::Ffi,
post_build: Vec::new(),
};
assert_eq!(
build_command_for(Language::Zig, &build_config, &config, false),
"cd packages/zig && zig build"
);
assert_eq!(
build_command_for(Language::Zig, &build_config, &config, true),
"cd packages/zig && zig build --release=fast"
);
}
#[test]
fn gleam_build_command_uses_gleam_build() {
let alef_cfg: crate::core::config::NewAlefConfig = toml::from_str(
r#"
[workspace]
languages = ["gleam"]
[[crates]]
name = "sample-lib"
sources = ["src/lib.rs"]
"#,
)
.unwrap();
let config = alef_cfg.resolve().unwrap().remove(0);
let build_config = BuildConfig {
tool: "gleam",
crate_suffix: "",
build_dep: BuildDependency::Rustler,
post_build: Vec::new(),
};
assert_eq!(
build_command_for(Language::Gleam, &build_config, &config, false),
"cd packages/gleam && gleam build"
);
assert_eq!(
build_command_for(Language::Gleam, &build_config, &config, true),
"cd packages/gleam && gleam build"
);
}
#[test]
fn c_language_is_skipped_gracefully_instead_of_panicking() {
let config = ResolvedCrateConfig::default();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| build(&config, &[Language::C], false)));
match result {
Ok(build_result) => assert!(
build_result.is_ok(),
"C has no binding backend and must be skipped cleanly: {build_result:?}"
),
Err(_) => panic!("building an unsupported binding target must not panic"),
}
}
#[test]
fn rewrite_wasm_package_json_name_only_touches_the_name_field() {
let dir = tempfile::tempdir().expect("failed to create temp dir");
let manifest_path = dir.path().join("package.json");
std::fs::write(
&manifest_path,
r#"{
"name": "sample-core-wasm",
"version": "0.1.0",
"description": "not a name field: \"name\" appears here too",
"main": "sample_core_wasm.js"
}
"#,
)
.expect("failed to write fixture package.json");
rewrite_wasm_package_json_name(&manifest_path, "@xberg-io/sample-crate-wasm").expect("rewrite must succeed");
let rewritten = std::fs::read_to_string(&manifest_path).expect("failed to read rewritten package.json");
assert!(
rewritten.contains(r#""name": "@xberg-io/sample-crate-wasm""#),
"{rewritten}"
);
assert!(!rewritten.contains("sample-core-wasm"), "{rewritten}");
assert!(
rewritten.contains(r#""main": "sample_core_wasm.js""#),
"unrelated fields must survive untouched: {rewritten}"
);
assert!(
rewritten.contains(r#"not a name field: \"name\" appears here too"#),
"a `\"name\"` substring inside another field's value must not be touched: {rewritten}"
);
}
#[test]
fn rewrite_wasm_package_json_name_is_idempotent() {
let dir = tempfile::tempdir().expect("failed to create temp dir");
let manifest_path = dir.path().join("package.json");
std::fs::write(&manifest_path, r#"{"name": "already-correct"}"#).expect("failed to write fixture");
rewrite_wasm_package_json_name(&manifest_path, "already-correct").expect("rewrite must succeed");
let rewritten = std::fs::read_to_string(&manifest_path).expect("failed to read package.json");
assert_eq!(rewritten, r#"{"name": "already-correct"}"#);
}
#[test]
fn run_post_build_rewrites_wasm_package_json_name_relative_to_base_dir() {
let base_dir = tempfile::tempdir().expect("failed to create temp dir");
let pkg_dir = base_dir.path().join("crates/sample-lib-wasm/pkg/nodejs");
std::fs::create_dir_all(&pkg_dir).expect("failed to create pkg/nodejs");
std::fs::write(pkg_dir.join("package.json"), r#"{"name": "sample-lib-wasm-crate"}"#)
.expect("failed to write fixture package.json");
let alef_cfg: crate::core::config::NewAlefConfig = toml::from_str(
r#"
[workspace]
languages = ["wasm"]
[[crates]]
name = "sample-lib"
sources = ["src/lib.rs"]
"#,
)
.unwrap();
let config = alef_cfg.resolve().unwrap().remove(0);
let build_config = BuildConfig {
tool: "wasm-pack",
crate_suffix: "-wasm",
build_dep: BuildDependency::None,
post_build: vec![crate::core::backend::PostBuildStep::RewriteWasmPackageName {
package_json_path: std::path::PathBuf::from("crates/sample-lib-wasm/pkg/nodejs/package.json"),
package_name: config.wasm_package_name(),
}],
};
run_post_build(Language::Wasm, &build_config, &config, base_dir.path()).expect("post-build must succeed");
let rewritten =
std::fs::read_to_string(pkg_dir.join("package.json")).expect("failed to read rewritten package.json");
assert!(
rewritten.contains(&format!(r#""name": "{}""#, config.wasm_package_name())),
"{rewritten}"
);
}
}
#[cfg(all(test, unix))]
mod build_orchestration_tests {
use super::*;
fn hermetic_config(toml: &str) -> ResolvedCrateConfig {
let alef_cfg: crate::core::config::NewAlefConfig = toml::from_str(toml).unwrap();
alef_cfg.resolve().unwrap().remove(0)
}
#[test]
fn one_backend_failure_does_not_block_the_others() {
let marker_dir = tempfile::tempdir().expect("failed to create temp dir for build markers");
let marker_node = marker_dir.path().join("node.built");
let marker_go = marker_dir.path().join("go.built");
let config = hermetic_config(&format!(
r#"
[workspace]
languages = ["php", "node", "ffi", "go"]
[workspace.build_commands.php]
precondition = "true"
build = "false"
[workspace.build_commands.node]
precondition = "true"
build = "touch {node_marker}"
[workspace.build_commands.ffi]
precondition = "true"
build = "true"
[workspace.build_commands.go]
precondition = "true"
build = "touch {go_marker}"
[[crates]]
name = "orchestration-test-lib"
sources = ["src/lib.rs"]
"#,
node_marker = marker_node.display(),
go_marker = marker_go.display(),
));
let result = build(
&config,
&[Language::Php, Language::Node, Language::Ffi, Language::Go],
false,
);
assert!(result.is_err(), "php's failure must surface in the aggregate result");
let message = format!("{:#}", result.unwrap_err());
assert!(
message.contains("php"),
"aggregate error must name the failed language: {message}"
);
assert!(
marker_node.exists(),
"node (independent, ordered after php in the list) must still be attempted and succeed"
);
assert!(
marker_go.exists(),
"go (ffi_dependent) must still be attempted and succeed even though the independent \
stage had a failure — that's the class of language that used to be silently dropped"
);
}
}
#[cfg(all(test, unix))]
mod run_command_tests {
use super::*;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn restore_skip_env(previous: Option<String>) {
unsafe {
match previous {
Some(value) => std::env::set_var("ALEF_SKIP_COMMANDS", value),
None => std::env::remove_var("ALEF_SKIP_COMMANDS"),
}
}
}
#[test]
fn run_run_command_succeeds_for_echo() {
let _guard = env_lock().lock().expect("env lock poisoned");
let previous = std::env::var("ALEF_SKIP_COMMANDS").ok();
unsafe {
std::env::remove_var("ALEF_SKIP_COMMANDS");
}
let dir = std::env::temp_dir();
let result = run_run_command("echo", &["alef-runcommand-ok"], &dir, "sample");
restore_skip_env(previous);
assert!(result.is_ok(), "echo should succeed: {result:?}");
}
#[test]
fn run_run_command_fails_for_false() {
let _guard = env_lock().lock().expect("env lock poisoned");
let previous = std::env::var("ALEF_SKIP_COMMANDS").ok();
unsafe {
std::env::remove_var("ALEF_SKIP_COMMANDS");
}
let dir = std::env::temp_dir();
let result = run_run_command("false", &[], &dir, "sample");
restore_skip_env(previous);
assert!(result.is_err(), "false should return Err");
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("exited with status"),
"error should mention exit status: {msg}"
);
}
#[test]
fn run_run_command_honors_skip_env_var() {
let _guard = env_lock().lock().expect("env lock poisoned");
let previous = std::env::var("ALEF_SKIP_COMMANDS").ok();
let dir = std::env::temp_dir();
unsafe {
std::env::set_var("ALEF_SKIP_COMMANDS", "noop,false , another");
}
let skipped = run_run_command("false", &[], &dir, "sample");
assert!(
skipped.is_ok(),
"listed command must return Ok without spawning: {skipped:?}"
);
unsafe {
std::env::set_var("ALEF_SKIP_COMMANDS", "something-else");
}
let honored = run_run_command("false", &[], &dir, "sample");
restore_skip_env(previous);
assert!(
honored.is_err(),
"unlisted command must still spawn and surface failure"
);
}
}