use crate::cli::pipeline::helpers::{
check_precondition, precondition_passes, run_before, run_command_captured_with_env, run_command_with_env,
};
use crate::cli::registry;
use crate::core::config::{BuildCommandConfig, Language, ResolvedCrateConfig};
use anyhow::Context as _;
use rayon::prelude::*;
use std::path::Path;
use tracing::{debug, info, warn};
mod build_command;
mod frb_bridge_coverage;
mod frb_cache;
mod frb_cfg_gates;
mod frb_version_check;
mod observability;
use build_command::{build_command_for, output_path_for, resolve_crate_dir};
#[cfg(test)]
mod build_command_tests;
#[cfg(all(test, unix))]
mod build_orchestration_tests;
#[cfg(all(test, unix))]
mod napi_js_ownership_tests;
#[cfg(all(test, unix))]
mod napi_package_json_path_tests;
#[cfg(test)]
mod readiness_tests;
#[cfg(test)]
mod record_post_build_outcome_tests;
#[cfg(all(test, unix))]
mod run_command_tests;
pub(crate) use frb_cfg_gates::canonical_frb_generated;
pub fn build(config: &ResolvedCrateConfig, languages: &[Language], release: bool, strict: bool) -> anyhow::Result<()> {
build_with_environment(config, languages, release, &[], strict)
}
pub(crate) fn build_with_environment(
config: &ResolvedCrateConfig,
languages: &[Language],
release: bool,
environment: &[(&str, &str)],
strict: 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();
let mut toolchain_missing: 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 { precondition } => {
observability::skipped(lang, "required tool is not on PATH");
skipped_count += 1;
toolchain_missing.push(format!("{lang} (precondition failed: {precondition})"));
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();
let mut skipped_post_build_tools: 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_with_env(cmd, environment).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_root = output_path_for(Language::Ffi, config)
.map(resolve_crate_dir)
.and_then(|p| p.to_str())
.map(str::to_string)
.or_else(|| crate::core::config::resolve_helpers::default_binding_crate_root(crate_name, "ffi"))
.unwrap_or_else(|| format!("crates/{crate_name}-ffi"));
info!("Building FFI crate: {ffi_crate_root}");
let mut cmd = format!("cargo build --manifest-path {ffi_crate_root}/Cargo.toml");
if release {
cmd.push_str(" --release");
}
let result = observability::observe(Language::Ffi, || {
run_command_with_env(&cmd, environment).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_with_env(cmd, environment)
.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_with_env(&build_cmd, environment)
.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}");
}
record_post_build_outcome(
*lang,
run_post_build(*lang, bc, config, &base_dir),
&mut failures,
&mut skipped_post_build_tools,
);
}
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_with_env(cmd, environment)
.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_with_env(&build_cmd, environment)
.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}");
}
record_post_build_outcome(
*lang,
run_post_build(*lang, bc, config, &base_dir),
&mut failures,
&mut skipped_post_build_tools,
);
}
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 ({} skipped for a missing \
toolchain), {} blocked on unmet preconditions, {dispatched_count} dispatched, {} language-level \
failure(s), {} post-build tool(s) skipped (not on PATH, falling back to committed output)",
toolchain_missing.len(),
unmet.len(),
failures.len(),
skipped_post_build_tools.len()
);
if !toolchain_missing.is_empty() {
if strict {
warn!(
"--strict is set: {} language(s) skipped for a missing toolchain will fail this run: {}",
toolchain_missing.len(),
toolchain_missing.join(", ")
);
} else {
info!(
"{} language(s) skipped for a missing toolchain (non-fatal; pass --strict in CI to fail on this): \
{}",
toolchain_missing.len(),
toolchain_missing.join(", ")
);
}
}
build_outcome(&failures, &unmet, &toolchain_missing, strict)
}
fn build_outcome(
failures: &[String],
unmet: &[String],
toolchain_missing: &[String],
strict: bool,
) -> 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 strict && !toolchain_missing.is_empty() {
parts.push(format!(
"--strict is set and {} language(s) were skipped because their toolchain is not on PATH (no build \
was attempted): {}",
toolchain_missing.len(),
toolchain_missing.join(", ")
));
}
if parts.is_empty() {
return Ok(());
}
anyhow::bail!("{}", parts.join(" | "));
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum BackendReadiness {
Ready,
ToolchainMissing {
precondition: String,
},
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 {
precondition: build_cmd_cfg.precondition.clone().unwrap_or_default(),
};
}
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,
}
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct PostBuildOutcome {
pub skipped_missing_tools: Vec<String>,
}
fn record_post_build_outcome(
lang: Language,
result: anyhow::Result<PostBuildOutcome>,
failures: &mut Vec<String>,
skipped_post_build_tools: &mut Vec<String>,
) {
match result {
Ok(outcome) => {
for tool in outcome.skipped_missing_tools {
warn!(
"[{lang}] post-build completed but skipped '{tool}' (not on PATH) -- falling back to \
committed generated files"
);
skipped_post_build_tools.push(format!("{lang}: {tool}"));
}
}
Err(err) => failures.push(format!("{lang}: post-build failed: {err:#}")),
}
}
pub fn run_post_build(
lang: Language,
bc: &crate::core::backend::BuildConfig,
config: &ResolvedCrateConfig,
base_dir: &Path,
) -> anyhow::Result<PostBuildOutcome> {
use crate::core::backend::PostBuildStep;
let crate_dir = output_path_for(lang, config)
.map(resolve_crate_dir)
.unwrap_or(Path::new(""));
let mut skipped_missing_tools: Vec<String> = Vec::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);
let timeout = config
.build_command_config_for_language(lang)
.timeout_seconds
.map(std::time::Duration::from_secs)
.unwrap_or(RUN_COMMAND_TIMEOUT);
let ran = run_run_command(cmd, args, &work_dir, &config.name, timeout)
.with_context(|| format!("post-build RunCommand '{cmd}' failed"))?;
if !ran {
skipped_missing_tools.push((*cmd).to_string());
}
}
PostBuildStep::VerifyFrbCodegenVersion { expected_version } => {
frb_version_check::run(frb_version_check::FLUTTER_RUST_BRIDGE_CODEGEN, expected_version)
.context("post-build VerifyFrbCodegenVersion 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);
frb_cfg_gates::run(&source_file, &target_file)?;
}
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,
true,
)
.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::VerifyFrbBridgeCoverage {
facade_path,
bridge_path,
exclude_functions,
} => {
let facade_file = base_dir.join(crate_dir).join(facade_path);
let bridge_file = base_dir.join(crate_dir).join(bridge_path);
frb_bridge_coverage::verify(&facade_file, &bridge_file, exclude_functions)?;
}
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(PostBuildOutcome { skipped_missing_tools })
}
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,
timeout: std::time::Duration,
) -> anyhow::Result<bool> {
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(false);
}
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(false);
}
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() > timeout {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!("'{cmd}' exceeded {}s timeout; killed", 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(true)
}