mbus-ffi 0.16.0

Native C FFI and browser WASM bindings for modbus-rs client APIs, with optional generated server bindings
Documentation
fn main() {
    println!("cargo::rustc-check-cfg=cfg(cbindgen)");
    println!("cargo::rustc-check-cfg=cfg(has_unwind)");

    // napi-build setup for Node.js bindings — generates the native addon registration glue.
    // Only called when the `nodejs` feature is active.
    if std::env::var("CARGO_FEATURE_NODEJS").is_ok() {
        napi_build::setup();
    }

    let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();

    // Link the C standard library for intrinsics (memcpy, memset, strlen, etc.)
    // These are generated by the compiler for no_std code and need libc implementations.
    // Windows MSVC has no "c" library; the CRT is linked automatically by rustc.
    let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default();
    if target_arch != "wasm32" && target_env != "msvc" {
        println!("cargo::rustc-link-lib=c");
    }

    // For macOS, suppress the undefined symbol error for _rust_eh_personality
    // by allowing undefined symbols in the dylib. The C side won't use unwinding anyway.
    #[cfg(target_os = "macos")]
    if target_arch != "wasm32" {
        println!("cargo::rustc-link-arg=-undefined");
        println!("cargo::rustc-link-arg=dynamic_lookup");
    }

    // Read CARGO_CFG_PANIC to detect if we are building with unwinding (e.g. during cargo test)
    if let Ok(panic_strat) = std::env::var("CARGO_CFG_PANIC")
        && panic_strat == "unwind"
    {
        println!("cargo::rustc-cfg=has_unwind");
    }

    // ── MBUS_MAX_TCP_CLIENTS ──────────────────────────────────────────────────
    //
    // Controls how many TCP client slots are pre-allocated in the static pool.
    // Default: 1. Valid range: [1, 255].
    // IDs 0x00xx index the TCP pool (high byte = 0x00).
    println!("cargo::rerun-if-env-changed=MBUS_MAX_TCP_CLIENTS");

    let max_tcp: usize = match std::env::var("MBUS_MAX_TCP_CLIENTS") {
        Ok(val) => {
            let n: usize = val.parse().unwrap_or_else(|_| {
                panic!("MBUS_MAX_TCP_CLIENTS must be a valid integer, got: \"{val}\"")
            });
            if n == 0 {
                panic!("MBUS_MAX_TCP_CLIENTS must be >= 1, got: 0");
            }
            if n > 255 {
                panic!("MBUS_MAX_TCP_CLIENTS must be <= 255, got: {n}");
            }
            n
        }
        Err(_) => 1, // default
    };

    // ── MBUS_MAX_SERIAL_CLIENTS ───────────────────────────────────────────────
    //
    // Controls how many Serial client slots are pre-allocated in each of the
    // RTU and ASCII sub-pools. Default: 1. Valid range: [1, 255].
    // IDs 0x01xx index the RTU pool, 0x02xx the ASCII pool.
    println!("cargo::rerun-if-env-changed=MBUS_MAX_SERIAL_CLIENTS");

    let max_serial: usize = match std::env::var("MBUS_MAX_SERIAL_CLIENTS") {
        Ok(val) => {
            let n: usize = val.parse().unwrap_or_else(|_| {
                panic!("MBUS_MAX_SERIAL_CLIENTS must be a valid integer, got: \"{val}\"")
            });
            if n == 0 {
                panic!("MBUS_MAX_SERIAL_CLIENTS must be >= 1, got: 0");
            }
            if n > 255 {
                panic!("MBUS_MAX_SERIAL_CLIENTS must be <= 255, got: {n}");
            }
            n
        }
        Err(_) => 1, // default
    };

    // ── MBUS_MAX_TCP_SERVERS ──────────────────────────────────────────────────
    //
    // Controls how many TCP server slots are pre-allocated in the static server pool.
    // Default: 1. Valid range: [1, 255].
    // IDs 0x10xx index the TCP server pool.
    println!("cargo::rerun-if-env-changed=MBUS_MAX_TCP_SERVERS");

    let max_tcp_servers: usize = match std::env::var("MBUS_MAX_TCP_SERVERS") {
        Ok(val) => {
            let n: usize = val.parse().unwrap_or_else(|_| {
                panic!("MBUS_MAX_TCP_SERVERS must be a valid integer, got: \"{val}\"")
            });
            if n == 0 {
                panic!("MBUS_MAX_TCP_SERVERS must be >= 1, got: 0");
            }
            if n > 255 {
                panic!("MBUS_MAX_TCP_SERVERS must be <= 255, got: {n}");
            }
            n
        }
        Err(_) => 1, // default
    };

    // ── MBUS_MAX_SERIAL_SERVERS ───────────────────────────────────────────────
    //
    // Controls how many Serial server slots are pre-allocated in the static server pool.
    // Default: 1. Valid range: [1, 255].
    // IDs 0x11xx index the Serial server pool.
    println!("cargo::rerun-if-env-changed=MBUS_MAX_SERIAL_SERVERS");

    let max_serial_servers: usize = match std::env::var("MBUS_MAX_SERIAL_SERVERS") {
        Ok(val) => {
            let n: usize = val.parse().unwrap_or_else(|_| {
                panic!("MBUS_MAX_SERIAL_SERVERS must be a valid integer, got: \"{val}\"")
            });
            if n == 0 {
                panic!("MBUS_MAX_SERIAL_SERVERS must be >= 1, got: 0");
            }
            if n > 255 {
                panic!("MBUS_MAX_SERIAL_SERVERS must be <= 255, got: {n}");
            }
            n
        }
        Err(_) => 1, // default
    };

    // ── MBUS_MAX_GATEWAYS ─────────────────────────────────────────────────────
    //
    // Controls how many gateway slots are pre-allocated in the static gateway pool.
    // Default: 1. Valid range: [1, 255].
    println!("cargo::rerun-if-env-changed=MBUS_MAX_GATEWAYS");

    let max_gateways: usize = match std::env::var("MBUS_MAX_GATEWAYS") {
        Ok(val) => {
            let n: usize = val.parse().unwrap_or_else(|_| {
                panic!("MBUS_MAX_GATEWAYS must be a valid integer, got: \"{val}\"")
            });
            if n == 0 {
                panic!("MBUS_MAX_GATEWAYS must be >= 1, got: 0");
            }
            if n > 255 {
                panic!("MBUS_MAX_GATEWAYS must be <= 255, got: {n}");
            }
            n
        }
        Err(_) => 1, // default
    };

    let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set");
    let config_path = format!("{out_dir}/pool_config.rs");
    std::fs::write(
        &config_path,
        format!(
            "/// Maximum number of TCP client slots (set via `MBUS_MAX_TCP_CLIENTS` env var, default 1).\n\
             #[allow(dead_code)]\n\
             pub(crate) const MAX_TCP_CLIENTS: usize = {max_tcp};\n\
             /// Maximum number of Serial client slots (set via `MBUS_MAX_SERIAL_CLIENTS` env var, default 1).\n\
             #[allow(dead_code)]\n\
             pub(crate) const MAX_SERIAL_CLIENTS: usize = {max_serial};\n\
             /// Maximum number of TCP server slots (set via `MBUS_MAX_TCP_SERVERS` env var, default 1).\n\
             #[allow(dead_code)]\n\
             pub(crate) const MAX_TCP_SERVERS: usize = {max_tcp_servers};\n\
             /// Maximum number of Serial server slots (set via `MBUS_MAX_SERIAL_SERVERS` env var, default 1).\n\
             #[allow(dead_code)]\n\
             pub(crate) const MAX_SERIAL_SERVERS: usize = {max_serial_servers};\n\
             /// Maximum number of gateway slots (set via `MBUS_MAX_GATEWAYS` env var, default 1).\n\
             #[allow(dead_code)]\n\
             pub(crate) const MAX_GATEWAYS: usize = {max_gateways};\n"
        ),
    )
    .expect("failed to write pool_config.rs");

    // Only run cbindgen when the `c-client`, `c-server`, `c-gateway`, `dotnet`, or `go` feature is enabled.
    if std::env::var("CARGO_FEATURE_C_CLIENT").is_err()
        && std::env::var("CARGO_FEATURE_C_SERVER").is_err()
        && std::env::var("CARGO_FEATURE_C_GATEWAY").is_err()
        && std::env::var("CARGO_FEATURE_DOTNET").is_err()
        && std::env::var("CARGO_FEATURE_GO").is_err()
    {
        return;
    }

    let crate_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set");
    let workspace_root = std::path::Path::new(&crate_dir)
        .parent()
        .expect("CARGO_MANIFEST_DIR has no parent")
        .to_path_buf();
    let target_dir = std::env::var("CARGO_TARGET_DIR")
        .map(std::path::PathBuf::from)
        .unwrap_or_else(|_| workspace_root.join("target"));
    let include_dir = target_dir.join("mbus-ffi/include");

    // Rerun cbindgen if the configs or any source file changes.
    println!("cargo::rerun-if-changed=cbindgen.toml");
    println!("cargo::rerun-if-changed=src");
    std::fs::create_dir_all(&include_dir).expect("failed to create target include directory");

    // Option A: Single consolidated generation
    let output_file = include_dir.join("modbus_rs.h");
    let config_path = format!("{crate_dir}/cbindgen.toml");
    let mut config = cbindgen::Config::from_file(&config_path)
        .unwrap_or_else(|err| panic!("failed to parse {config_path}: {err}"));

    let active_defines = get_active_defines();
    config.autogen_warning = Some(format!(
        "/* Auto-generated by cbindgen - DO NOT EDIT */\n{}",
        active_defines
    ));

    cbindgen::Builder::new()
        .with_crate(&crate_dir)
        .with_language(cbindgen::Language::C)
        .with_define("feature", "c-client", "MBUS_FEATURE_C_CLIENT")
        .with_define("feature", "c-server", "MBUS_FEATURE_C_SERVER")
        .with_define("feature", "c-gateway", "MBUS_FEATURE_C_GATEWAY")
        .with_define("feature", "coils", "MBUS_FEATURE_COILS")
        .with_define(
            "feature",
            "holding-registers",
            "MBUS_FEATURE_HOLDING_REGISTERS",
        )
        .with_define("feature", "input-registers", "MBUS_FEATURE_INPUT_REGISTERS")
        .with_define("feature", "discrete-inputs", "MBUS_FEATURE_DISCRETE_INPUTS")
        .with_define("feature", "fifo", "MBUS_FEATURE_FIFO")
        .with_define("feature", "file-record", "MBUS_FEATURE_FILE_RECORD")
        .with_define("feature", "diagnostics", "MBUS_FEATURE_DIAGNOSTICS")
        .with_define("feature", "traffic", "MBUS_FEATURE_TRAFFIC")
        .with_define("feature", "server-traffic", "MBUS_FEATURE_SERVER_TRAFFIC")
        .with_define("feature", "network-tcp", "MBUS_FEATURE_NETWORK_TCP")
        .with_define("feature", "serial-rtu", "MBUS_FEATURE_SERIAL_RTU")
        .with_define("feature", "serial-ascii", "MBUS_FEATURE_SERIAL_ASCII")
        .with_define("feature", "dotnet", "MBUS_FEATURE_DOTNET")
        .with_define("feature", "go", "MBUS_FEATURE_GO")
        .with_config(config)
        .generate()
        .expect("cbindgen failed to generate unified C header")
        .write_to_file(&output_file);

    // Mirror to all sibling headers to keep everything perfectly compatible
    let client_h = include_dir.join("modbus_rs_client.h");
    let server_h = include_dir.join("modbus_rs_server.h");
    let gateway_h = include_dir.join("modbus_rs_gateway.h");
    let dotnet_h = include_dir.join("modbus_rs_dotnet.h");
    let go_h = include_dir.join("modbus_rs_go.h");

    let _ = std::fs::copy(&output_file, &client_h);
    let _ = std::fs::copy(&output_file, &server_h);
    let _ = std::fs::copy(&output_file, &gateway_h);
    let _ = std::fs::copy(&output_file, &dotnet_h);
    let _ = std::fs::copy(&output_file, &go_h);

    // Go-specific: copy to internal go include dir if exists and the `go` feature is enabled
    if std::env::var("CARGO_FEATURE_GO").is_ok() {
        let go_include_dir = std::path::Path::new(&crate_dir)
            .join("go")
            .join("internal")
            .join("cgo")
            .join("include");
        if go_include_dir.exists() {
            let dst = go_include_dir.join("modbus_rs_go.h");
            let _ = std::fs::copy(&go_h, &dst);
        }
    }

    // ── Server app code generation ────────────────────────────────────────
    if std::env::var("CARGO_FEATURE_C_SERVER").is_ok() {
        println!("cargo::rerun-if-env-changed=MBUS_SERVER_APP_CONFIG");
        let example_yaml = std::path::Path::new(&crate_dir)
            .join("examples/c_server_demo_yaml/mbus_server_app.example.yaml");
        let app_config_raw = std::env::var("MBUS_SERVER_APP_CONFIG").unwrap_or_else(|_| {
            if example_yaml.exists() {
                example_yaml.to_string_lossy().into_owned()
            } else {
                panic!(
                    "\n\nError: MBUS_SERVER_APP_CONFIG is not set.\n\
                     The `c-server` feature requires a YAML device config to generate the server app layer.\n\
                     Set the environment variable to the path of your server_app.yaml:\n\
                     \n\
                     MBUS_SERVER_APP_CONFIG=/path/to/server_app.yaml cargo build -p mbus-ffi --features c-server\n\
                     \n\
                     See mbus-ffi/examples/c_server_demo_yaml/mbus_server_app.example.yaml for the format.\n"
                )
            }
        });
        let app_config_path = {
            let p = std::path::Path::new(&app_config_raw);
            if p.is_absolute() {
                p.to_path_buf()
            } else {
                std::path::Path::new(&crate_dir)
                    .parent()
                    .expect("CARGO_MANIFEST_DIR has no parent")
                    .join(p)
            }
        };
        println!("cargo::rerun-if-changed={}", app_config_path.display());
        let app_config_text = std::fs::read_to_string(&app_config_path).unwrap_or_else(|e| {
            panic!(
                "failed to read MBUS_SERVER_APP_CONFIG={}: {e}",
                app_config_path.display()
            )
        });
        let mut app_config = mbus_codegen::parse_yaml(&app_config_text)
            .unwrap_or_else(|e| panic!("invalid YAML in {}: {e}", app_config_path.display()));
        mbus_codegen::validate_config(&app_config)
            .unwrap_or_else(|e| panic!("config error in {}: {e}", app_config_path.display()));

        if std::env::var("CARGO_FEATURE_COILS").is_err() && !app_config.memory_map.coils.is_empty()
        {
            println!(
                "cargo::warning=YAML defines {} coil(s) but the `coils` feature is not enabled; coil handlers will NOT be compiled.",
                app_config.memory_map.coils.len()
            );
            app_config.memory_map.coils.clear();
        }
        if std::env::var("CARGO_FEATURE_DISCRETE_INPUTS").is_err()
            && !app_config.memory_map.discrete_inputs.is_empty()
        {
            println!(
                "cargo::warning=YAML defines {} discrete input(s) but the `discrete-inputs` feature is not enabled; discrete-input handlers will NOT be compiled.",
                app_config.memory_map.discrete_inputs.len()
            );
            app_config.memory_map.discrete_inputs.clear();
        }
        if std::env::var("CARGO_FEATURE_HOLDING_REGISTERS").is_err()
            && !app_config.memory_map.holding_registers.is_empty()
        {
            println!(
                "cargo::warning=YAML defines {} holding register(s) but the `holding-registers` feature is not enabled; holding-register handlers will NOT be compiled.",
                app_config.memory_map.holding_registers.len()
            );
            app_config.memory_map.holding_registers.clear();
        }
        if std::env::var("CARGO_FEATURE_INPUT_REGISTERS").is_err()
            && !app_config.memory_map.input_registers.is_empty()
        {
            println!(
                "cargo::warning=YAML defines {} input register(s) but the `input-registers` feature is not enabled; input-register handlers will NOT be compiled.",
                app_config.memory_map.input_registers.len()
            );
            app_config.memory_map.input_registers.clear();
        }

        let rust_src = mbus_codegen::render_rust_dispatcher(&app_config);
        let gen_path = format!("{out_dir}/generated_server.rs");
        std::fs::write(&gen_path, rust_src)
            .unwrap_or_else(|e| panic!("failed to write {gen_path}: {e}"));
    }
}

fn get_active_defines() -> String {
    let mut s = String::new();
    s.push_str("\n/* Active feature configuration defines built into the library */\n");

    let features = [
        ("CARGO_FEATURE_C_CLIENT", "MBUS_FEATURE_C_CLIENT"),
        ("CARGO_FEATURE_C_SERVER", "MBUS_FEATURE_C_SERVER"),
        ("CARGO_FEATURE_C_GATEWAY", "MBUS_FEATURE_C_GATEWAY"),
        ("CARGO_FEATURE_COILS", "MBUS_FEATURE_COILS"),
        (
            "CARGO_FEATURE_HOLDING_REGISTERS",
            "MBUS_FEATURE_HOLDING_REGISTERS",
        ),
        (
            "CARGO_FEATURE_INPUT_REGISTERS",
            "MBUS_FEATURE_INPUT_REGISTERS",
        ),
        (
            "CARGO_FEATURE_DISCRETE_INPUTS",
            "MBUS_FEATURE_DISCRETE_INPUTS",
        ),
        ("CARGO_FEATURE_FIFO", "MBUS_FEATURE_FIFO"),
        ("CARGO_FEATURE_FILE_RECORD", "MBUS_FEATURE_FILE_RECORD"),
        ("CARGO_FEATURE_DIAGNOSTICS", "MBUS_FEATURE_DIAGNOSTICS"),
        ("CARGO_FEATURE_TRAFFIC", "MBUS_FEATURE_TRAFFIC"),
        (
            "CARGO_FEATURE_SERVER_TRAFFIC",
            "MBUS_FEATURE_SERVER_TRAFFIC",
        ),
        ("CARGO_FEATURE_NETWORK_TCP", "MBUS_FEATURE_NETWORK_TCP"),
        ("CARGO_FEATURE_SERIAL_RTU", "MBUS_FEATURE_SERIAL_RTU"),
        ("CARGO_FEATURE_SERIAL_ASCII", "MBUS_FEATURE_SERIAL_ASCII"),
        ("CARGO_FEATURE_DOTNET", "MBUS_FEATURE_DOTNET"),
        ("CARGO_FEATURE_GO", "MBUS_FEATURE_GO"),
    ];

    for &(cargo_feat, c_macro) in &features {
        if std::env::var(cargo_feat).is_ok() {
            s.push_str(&format!("#define {c_macro} 1\n"));
        } else {
            s.push_str(&format!("/* #undef {c_macro} */\n"));
        }
    }
    s
}