alef 0.25.4

Opinionated polyglot binding generator for Rust libraries
Documentation
use crate::cli::pipeline::helpers::{check_precondition, run_before, run_command_streamed_with_env};
use crate::cli::registry;
use crate::core::config::{Language, ResolvedCrateConfig};
use crate::publish::ffi_stage;
use crate::publish::platform::RustTarget;
use anyhow::Context as _;
use rayon::prelude::*;
use tracing::{info, warn};

pub fn test(config: &ResolvedCrateConfig, languages: &[Language], e2e: bool, coverage: bool) -> anyhow::Result<()> {
    // Compute pdfium dylib path from the workspace target directory.
    // Set DYLD_LIBRARY_PATH/LD_LIBRARY_PATH so pdfium-render can dlopen libpdfium.dylib at runtime.
    let pdfium_dir = compute_pdfium_dir();
    let mut env_vars: Vec<(&str, String)> = Vec::new();

    if let Some(lib_dir) = pdfium_dir {
        // Set platform-appropriate library search path
        #[cfg(target_os = "macos")]
        {
            // DYLD_LIBRARY_PATH is stripped by macOS SIP across some exec chains
            // (notably when child processes are signed/notarized).
            // DYLD_FALLBACK_LIBRARY_PATH is preserved and serves the same role for dlopen.
            env_vars.push(("DYLD_FALLBACK_LIBRARY_PATH", lib_dir.clone()));
            env_vars.push(("DYLD_LIBRARY_PATH", lib_dir));
        }
        #[cfg(target_os = "linux")]
        {
            env_vars.push(("LD_LIBRARY_PATH", lib_dir));
        }
        #[cfg(target_os = "windows")]
        {
            env_vars.push(("PATH", lib_dir));
        }
    }

    let base_dir = std::env::current_dir()?;

    // Phase 1 (sequential): run all per-language `before` hooks.
    //
    // The test phase used to run before hooks inside the same `par_iter` as the
    // test commands. Most consumer `[crates.test.<lang>] before = ...` entries
    // invoke `cargo build` against the shared `target/` directory, so running
    // them in parallel produces a textbook cargo race: one process deletes a
    // dep info file while another is writing it, surfacing as
    // "could not parse/generate dep info" / "No such file or directory" /
    // "failed to write bytecode" and assorted half-baked artifacts (e.g. the
    // flutter_rust_bridge `lib.dart` containing mismatched `value`/`field0`
    // factory params, because a sibling cargo build trampled the FRB emit).
    // The build phase already runs its before hooks sequentially (see `build`
    // above); mirror that here so the test phase is equally safe.
    let langs_to_test: Vec<Language> = languages
        .iter()
        .copied()
        .filter(|lang| {
            let lang_test = config.test_config_for_language(*lang);
            check_precondition(*lang, lang_test.precondition.as_deref())
        })
        .collect();
    for &lang in &langs_to_test {
        let lang_test = config.test_config_for_language(lang);
        if let Err(e) = run_before(lang, lang_test.before.as_ref()) {
            return Err(e).with_context(|| format!("before hook failed for {lang}"));
        }
    }

    // Phase 1b (sequential, only when `--e2e`): run each language's post-build
    // step before the parallel test phase. Many post-build steps shell out to
    // `cargo build` against the shared `target/` directory (swift, kotlin
    // android …); running them inside Phase 2's `par_iter` produces the same
    // textbook cargo race the before-hook serialization (Phase 1) was added
    // to avoid: rustc temp files (`*.rcgu.o.*`) fail with "No such file or
    // directory" when a sibling cargo invocation cleans the deps dir under
    // them. Doing the post-build pass serially before parallel tests start
    // mirrors the build pipeline's own serialization of post-build steps.
    if e2e {
        for &lang in &langs_to_test {
            let lang_test = config.test_config_for_language(lang);
            if lang_test.e2e.is_none() {
                continue;
            }
            let Some(backend) = registry::try_get_backend(lang) else {
                continue;
            };
            let Some(bc) = backend.build_config_with_config(config) else {
                continue;
            };
            if bc.post_build.is_empty() {
                continue;
            }
            if let Err(e) = super::run_post_build(lang, &bc, config, &base_dir) {
                eprintln!("  [{lang}] post-build processing failed before e2e tests: {e}");
                return Err(e).with_context(|| format!("post-build failed for {lang}"));
            }
        }

        // Stage FFI artifacts for FFI-dependent languages before e2e tests
        let host_target = get_host_target().context("failed to detect host target for FFI staging")?;
        for &lang in &langs_to_test {
            let lang_test = config.test_config_for_language(lang);
            if lang_test.e2e.is_none() {
                continue;
            }
            // Only stage for Go, Java, C# (FFI-dependent languages)
            if !matches!(lang, Language::Go | Language::Java | Language::Csharp) {
                continue;
            }
            let workspace_root = std::env::current_dir().ok().and_then(|cwd| {
                // Walk up to workspace root (directory containing target/ and alef.toml)
                let mut current = cwd;
                loop {
                    if current.join("target").exists() && current.join("alef.toml").exists() {
                        return Some(current);
                    }
                    if !current.pop() {
                        return None;
                    }
                }
            });
            if let Some(workspace_root) = workspace_root {
                match ffi_stage::stage_ffi(config, lang, &host_target, &workspace_root) {
                    Ok(dest) => {
                        info!("[{lang}] staged FFI artifacts to {}", dest.display());
                    }
                    Err(e) => {
                        // Log as warning but don't fail — FFI may not be built yet
                        warn!("[{lang}] failed to stage FFI artifacts: {e}");
                    }
                }
                // Optionally stage the header
                if let Ok(Some(header)) = ffi_stage::stage_header(config, lang, &host_target, &workspace_root) {
                    info!("[{lang}] staged FFI header to {}", header.display());
                }
            }
        }
    }

    // Phase 2 (parallel): run per-language test commands.
    let results: Vec<(Language, anyhow::Result<()>)> = langs_to_test
        .par_iter()
        .map(|lang| {
            let label = lang.to_string();
            let lang_test = config.test_config_for_language(*lang);

            // Use coverage commands when --coverage flag is set, fall back to regular test
            let test_cmds = if coverage {
                lang_test.coverage.as_ref().or(lang_test.command.as_ref())
            } else {
                lang_test.command.as_ref()
            };

            if let Some(cmd_list) = test_cmds {
                for cmd in cmd_list.commands() {
                    if let Err(e) = run_command_streamed_with_env(cmd, Some(&label), &env_vars) {
                        return (*lang, Err(e));
                    }
                }
            }
            if e2e && let Some(e2e_cmd_list) = &lang_test.e2e {
                for cmd in e2e_cmd_list.commands() {
                    if let Err(e) = run_command_streamed_with_env(cmd, Some(&label), &env_vars) {
                        return (*lang, Err(e));
                    }
                }
            }
            (*lang, Ok(()))
        })
        .collect();

    let mut first_error: Option<anyhow::Error> = None;
    for (lang, result) in results {
        if let Err(e) = result {
            eprintln!("✗ test failed: {lang}{e}");
            if first_error.is_none() {
                first_error = Some(e);
            }
        }
    }
    if let Some(e) = first_error {
        return Err(e);
    }

    Ok(())
}

/// Compute the target/release directory path from the workspace root.
///
/// Walks up from the current working directory to find any `target/release/`
/// containing a dynamic library (regardless of which one — pdfium, an FFI crate,
/// etc.). This directory is added to the dynamic library search path so that
/// e2e test runners (dotnet test, JVM, Node, …) can dlopen the workspace's
/// native libraries.
fn compute_pdfium_dir() -> Option<String> {
    use std::env;

    let (lib_prefix, lib_ext): (&str, &str) = if cfg!(target_os = "macos") {
        ("lib", ".dylib")
    } else if cfg!(target_os = "windows") {
        ("", ".dll")
    } else {
        ("lib", ".so")
    };

    let mut current = env::current_dir().ok()?;

    loop {
        let target_release = current.join("target").join("release");
        if target_release.exists() {
            // Accept the directory if it contains *any* native library matching
            // the platform's prefix/extension. We don't care which library — the
            // DYLD path is shared by every test process that dlopens FFI deps.
            if let Ok(entries) = std::fs::read_dir(&target_release) {
                for entry in entries.flatten() {
                    let name = entry.file_name();
                    let Some(name_str) = name.to_str() else { continue };
                    if name_str.starts_with(lib_prefix) && name_str.ends_with(lib_ext) {
                        if let Some(path_str) = target_release.to_str() {
                            info!("Native library directory: {}", path_str);
                            return Some(path_str.to_string());
                        }
                    }
                }
            }
        }

        if !current.pop() {
            break;
        }
    }

    None
}

/// Get the current host Rust target triple by parsing rustc output.
///
/// Parses `rustc --version --verbose` to extract the `host:` line,
/// returning the target triple (e.g. `aarch64-apple-darwin`).
fn get_host_target() -> anyhow::Result<RustTarget> {
    use std::process::Command;

    let output = Command::new("rustc")
        .arg("--version")
        .arg("--verbose")
        .output()
        .context("failed to run rustc --version --verbose")?;

    if !output.status.success() {
        anyhow::bail!("rustc --version --verbose exited with non-zero status");
    }

    let stdout = String::from_utf8(output.stdout).context("rustc output is not valid UTF-8")?;

    for line in stdout.lines() {
        if let Some(triple) = line.strip_prefix("host:") {
            let triple = triple.trim();
            return RustTarget::parse(triple).with_context(|| format!("failed to parse host target triple: {triple}"));
        }
    }

    anyhow::bail!("rustc --version --verbose did not output a 'host:' line")
}