Skip to content

Generating Bindings with bindgen

21 min read

When you want to call a C library that has more than a couple of functions, hand-writing extern "C" declarations becomes tedious and error-prone. The bindgen crate reads a C header and generates the Rust FFI declarations for you at build time, so the C API stays the single source of truth.


bindgen parses a C (or C++) header using libclang and emits a Rust file full of extern "C" function declarations, #[repr(C)] structs, enums, and type aliases that exactly mirror the header. You run it from a build.rs build script, then include! the generated file into your crate.

For a TypeScript/JavaScript developer, this is the spiritual cousin of generating .d.ts type declarations from a .js library or running protoc/openapi-generator to turn a schema into typed client code. You point a tool at an interface definition and get type-safe glue back. The crucial difference: the bindings bindgen emits are raw and unsafe — they mirror the C types faithfully but provide none of Rust’s safety guarantees. Making them safe is your job, and this page shows the pattern.

Note: This page is about auto-generating bindings. If you only have one or two functions, hand-declaring them (see calling-c.md) is simpler. For the underlying extern "C" / #[repr(C)] mechanics, see ffi-basics.md.


In the Node.js world you rarely write FFI by hand either. A native addon ships a .node binary plus a generated .d.ts so TypeScript callers get types. A typical “wrap a C library” workflow with a tool such as node-gyp plus a typings generator looks conceptually like this:

// temperature.d.ts — GENERATED type declarations for a native addon.
// You did not write these by hand; a tool produced them from the C interface.
export const enum TempUnit {
Celsius = 0,
Fahrenheit = 1,
Kelvin = 2,
}
export interface Reading {
value: number;
unit: TempUnit;
}
export function tempConvert(reading: Reading, to: TempUnit): number;
export function tempConversionCount(): bigint;
// app.ts — your code consumes the generated declarations.
import { tempConvert, TempUnit } from "./temperature";
const f = tempConvert({ value: 100, unit: TempUnit.Celsius }, TempUnit.Fahrenheit);
console.log(`100C = ${f}F`); // 100C = 212F

The generated .d.ts only describes shapes for the TypeScript compiler — it is erased at runtime and enforces nothing once the program runs. bindgen’s output is the same idea (machine-generated glue from an interface) but it produces executable Rust declarations that the compiler actually links against.


We will wrap a small C temperature-conversion library. The project layout:

temp_conv/
├── Cargo.toml
├── build.rs # runs cc + bindgen at build time
├── csrc/
│ ├── temperature.h # the C interface we want to call
│ └── temperature.c # the C implementation we compile and link
└── src/
└── main.rs # raw bindings + our safe wrapper

The C header is the interface bindgen reads:

csrc/temperature.h
#ifndef TEMPERATURE_H
#define TEMPERATURE_H
#include <stdint.h>
/* A unit of temperature. */
typedef enum TempUnit {
TEMP_CELSIUS = 0,
TEMP_FAHRENHEIT = 1,
TEMP_KELVIN = 2,
} TempUnit;
/* A temperature reading: a value paired with its unit. */
typedef struct Reading {
double value;
TempUnit unit;
} Reading;
/* Convert a reading to a different unit. Returns the converted value. */
double temp_convert(Reading reading, TempUnit to);
/* Number of conversions performed since process start. */
uint64_t temp_conversion_count(void);
#endif
csrc/temperature.c
#include "temperature.h"
static uint64_t COUNT = 0;
static double to_celsius(Reading r) {
switch (r.unit) {
case TEMP_FAHRENHEIT: return (r.value - 32.0) * 5.0 / 9.0;
case TEMP_KELVIN: return r.value - 273.15;
default: return r.value;
}
}
double temp_convert(Reading reading, TempUnit to) {
COUNT += 1;
double c = to_celsius(reading);
switch (to) {
case TEMP_FAHRENHEIT: return c * 9.0 / 5.0 + 32.0;
case TEMP_KELVIN: return c + 273.15;
default: return c;
}
}
uint64_t temp_conversion_count(void) {
return COUNT;
}

Add the build-time tooling. Both are build-dependencies, not regular dependencies — they run during compilation, not at runtime. cargo add resolves the current versions automatically (it has been built into Cargo since 1.62, no cargo-edit needed):

Terminal window
cargo add bindgen --build # parses the header, emits Rust FFI declarations
cargo add cc --build # compiles temperature.c into a static lib we link
Cargo.toml
[package]
name = "temp_conv"
version = "0.1.0"
edition = "2024"
[build-dependencies]
bindgen = "0.72.1"
cc = "1.2.63"

The build script compiles the C, generates bindings, and writes them into OUT_DIR:

build.rs
use std::env;
use std::path::PathBuf;
fn main() {
// 1. Compile the C source into a static library and link it.
cc::Build::new()
.file("csrc/temperature.c")
.include("csrc")
.compile("temperature");
// 2. Re-run this script only if the header or source actually changes.
println!("cargo:rerun-if-changed=csrc/temperature.h");
println!("cargo:rerun-if-changed=csrc/temperature.c");
// 3. Generate Rust bindings from the C header.
let bindings = bindgen::Builder::default()
.header("csrc/temperature.h")
.allowlist_function("temp_.*") // only emit what we asked for
.allowlist_type("Reading")
.allowlist_type("TempUnit")
.rustified_enum("TempUnit") // make TempUnit a real Rust enum
.derive_default(true) // add #[derive(Default)] where safe
.generate()
.expect("unable to generate bindings");
// 4. Write the generated Rust into OUT_DIR for `include!`.
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out_path.join("bindings.rs"))
.expect("couldn't write bindings");
}

Finally, pull the generated file into a private ffi module and build a safe wrapper on top of it:

src/main.rs
#![allow(non_upper_case_globals, non_camel_case_types, non_snake_case)]
mod ffi {
// The generated file lives in OUT_DIR, not in your source tree.
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
}
/// A temperature value tagged with its unit (safe Rust mirror of the C enum).
#[derive(Clone, Copy, Debug)]
pub enum Unit {
Celsius,
Fahrenheit,
Kelvin,
}
impl From<Unit> for ffi::TempUnit {
fn from(u: Unit) -> Self {
match u {
Unit::Celsius => ffi::TempUnit::TEMP_CELSIUS,
Unit::Fahrenheit => ffi::TempUnit::TEMP_FAHRENHEIT,
Unit::Kelvin => ffi::TempUnit::TEMP_KELVIN,
}
}
}
/// Convert `value` (in `from` units) to `to` units. Safe wrapper over the C call.
pub fn convert(value: f64, from: Unit, to: Unit) -> f64 {
let reading = ffi::Reading { value, unit: from.into() };
// SAFETY: `Reading` is plain old data with no invariants; any `TempUnit`
// discriminant is valid, and `temp_convert` has no preconditions.
unsafe { ffi::temp_convert(reading, to.into()) }
}
/// How many conversions the C library has performed.
pub fn conversion_count() -> u64 {
// SAFETY: a parameterless C call that only reads a counter.
unsafe { ffi::temp_conversion_count() }
}
fn main() {
let f = convert(100.0, Unit::Celsius, Unit::Fahrenheit);
let k = convert(32.0, Unit::Fahrenheit, Unit::Kelvin);
println!("100C = {f}F");
println!("32F = {k}K");
println!("conversions so far: {}", conversion_count());
}

Running it produces this real output:

$ cargo run --quiet
100C = 212F
32F = 273.15K
conversions so far: 2

Note: bindgen needs libclang installed at build time (it is the C parser). On macOS it ships with the Xcode Command Line Tools; on Debian/Ubuntu install libclang-dev; on Windows install LLVM. If bindgen cannot find it, set LIBCLANG_PATH to the directory containing libclang.{so,dylib,dll}.


What bindgen actually wrote. The build.rs above produced this bindings.rs (shown verbatim, trimmed only of blank lines). Reading it teaches you exactly what bindgen does:

// $OUT_DIR/bindings.rs — generated by bindgen 0.72.1
#[repr(u32)]
#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)]
pub enum TempUnit {
TEMP_CELSIUS = 0,
TEMP_FAHRENHEIT = 1,
TEMP_KELVIN = 2,
}
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct Reading {
pub value: f64,
pub unit: TempUnit,
}
#[allow(clippy::unnecessary_operation, clippy::identity_op)]
const _: () = {
["Size of Reading"][::std::mem::size_of::<Reading>() - 16usize];
["Alignment of Reading"][::std::mem::align_of::<Reading>() - 8usize];
["Offset of field: Reading::value"][::std::mem::offset_of!(Reading, value) - 0usize];
["Offset of field: Reading::unit"][::std::mem::offset_of!(Reading, unit) - 8usize];
};
impl Default for Reading {
fn default() -> Self {
let mut s = ::std::mem::MaybeUninit::<Self>::uninit();
unsafe {
::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1);
s.assume_init()
}
}
}
unsafe extern "C" {
pub fn temp_convert(reading: Reading, to: TempUnit) -> f64;
}
unsafe extern "C" {
pub fn temp_conversion_count() -> u64;
}

Line by line:

  • #[repr(C)] struct Reading mirrors the C struct field-for-field. The #[repr(C)] attribute pins the field layout so it matches what the C compiler produced — Rust’s default repr(Rust) layout is deliberately unspecified and would not be ABI-compatible. (See ffi-basics.md for #[repr(C)] in depth.)
  • The const _: () = { ... } block is a compile-time layout assertion. bindgen bakes in the sizes and field offsets it observed from the C compiler; if your platform lays the struct out differently, this block fails to compile rather than silently corrupting memory. It costs nothing at runtime.
  • impl Default for Reading appears only because we asked with .derive_default(true). bindgen cannot #[derive(Default)] here (the value-then-unit layout needs zeroing) so it writes an explicit zero-initializing impl.
  • unsafe extern "C" { ... } are the raw function declarations. The unsafe extern block syntax is the latest-stable form (Rust 1.96.0, 2024 edition): every extern block is now written unsafe extern and each declared function is implicitly unsafe to call, because Rust cannot verify a foreign function upholds any of its safety contract.

Why OUT_DIR and include!. The build script writes into OUT_DIR, a per-build scratch directory Cargo provides, and src/main.rs pulls it in with include!(concat!(env!("OUT_DIR"), "/bindings.rs")). This keeps the multi-thousand-line generated file out of version control and guarantees it is regenerated whenever the header changes.

Why the #![allow(...)] at the top. bindgen preserves C naming (TEMP_CELSIUS, Reading), which violates Rust’s non_camel_case_types / non_upper_case_globals style lints. Without the allow attribute, the compiler warns:

warning: variant `TEMP_CELSIUS` should have an upper camel case name
warning: variant `TEMP_FAHRENHEIT` should have an upper camel case name
warning: variant `TEMP_KELVIN` should have an upper camel case name

Confining the bindings to a mod ffi { ... } and allowing those lints there is the standard way to silence the noise without disabling the lints crate-wide.

Why every call sits in unsafe. The generated functions are unsafe. Our convert and conversion_count wrap each call in a small unsafe { } block with a // SAFETY: comment justifying why the call is sound, then expose a safe signature. This is the unsafe-inside / safe-outside pattern that the rest of your program (and your tests) can use without writing unsafe again — covered in depth in safety-abstractions.md.


AspectTypeScript .d.ts generationRust bindgen
InputA .js/schema fileA C/C++ header (.h)
OutputType declarations, erased at runtimeReal extern "C" declarations the linker uses
Runtime effectNone (types are erased)The bindings are the call sites
Safety of outputN/A (no runtime enforcement)Raw and unsafe; you wrap them
When it runsBuild/tooling stepbuild.rs during cargo build
Layout correctnessNot applicableVerified by compile-time size_of/offset assertions

Rustified vs. constant enums. .rustified_enum("TempUnit") turns the C enum into a real Rust enum, which is ergonomic to match on. Without it, bindgen’s default is safer for arbitrary C: it emits a type alias plus constants, because a C enum value read from foreign memory could legally hold a discriminant your Rust enum does not list, and constructing an out-of-range Rust enum is undefined behavior. The default output looks like this:

// Default bindgen output for the same enum (no .rustified_enum call):
pub const TempUnit_TEMP_CELSIUS: TempUnit = 0;
pub const TempUnit_TEMP_FAHRENHEIT: TempUnit = 1;
pub const TempUnit_TEMP_KELVIN: TempUnit = 2;
pub type TempUnit = ::std::os::raw::c_uint;

Use .rustified_enum() only for enums you fully control and whose values you trust. For enums whose value comes back across the FFI boundary from C, prefer the default (or .newtype_enum()) to avoid UB. This is exactly the kind of trade-off the bindgen API forces you to think about, and it has no analog in .d.ts generation.

bindgen is descriptive, not safe. A generated .d.ts lying about a type just produces a TypeScript error you can ignore with as any. bindgen producing a wrong declaration (e.g. you hand-edited it, or the C compiler disagrees about layout) produces undefined behavior at runtime. The layout assertions catch the most common case; you remain responsible for the rest.


Pitfall 1: calling a generated function outside unsafe. The bindings are unsafe extern, so every call must be inside an unsafe block. Forgetting it is a hard error, not a warning:

// does not compile (error[E0133]): a generated extern fn called outside unsafe
fn main() {
let n = ffi::temp_conversion_count();
println!("{n}");
}

The real compiler output:

error[E0133]: call to unsafe function `ffi::temp_conversion_count` is unsafe and requires unsafe block
--> src/main.rs:5:13
|
5 | let n = ffi::temp_conversion_count();
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ call to unsafe function
|
= note: consult the function's documentation for information on how to avoid undefined behavior

The fix is the wrapper pattern shown above: one small unsafe { } block with a SAFETY: justification, exposed behind a safe fn.

Pitfall 2: forgetting rerun-if-changed. Without the cargo:rerun-if-changed=... lines, Cargo’s default heuristic re-runs build.rs whenever any file in the crate changes — but if you edit the C header in a way Cargo does not notice, your bindings can go stale. Always list the header (and source) explicitly so edits trigger regeneration.

Pitfall 3: generating the entire transitive header. Pointing bindgen at a header that #includes <stdint.h>, <stdio.h>, and friends will, by default, emit bindings for everything those pull in — thousands of lines and slow builds. Use allowlist_function, allowlist_type, and allowlist_var (regex-based) to emit only the API you actually call, as the example does with allowlist_function("temp_.*").

Pitfall 4: assuming bindgen makes things safe. It does not. A bindgen’d function that takes a *const c_char still expects a valid, NUL-terminated, correctly-aliased pointer. bindgen gives you a faithful but raw declaration; upholding the C function’s preconditions is entirely on you (see raw-pointers.md and calling-c.md).

Pitfall 5: missing libclang. If the build fails with a message about not finding libclang, it is an environment problem, not a code problem — install the platform’s clang/LLVM development package and, if needed, set LIBCLANG_PATH.


  • Confine bindings to a private mod ffi with #![allow(non_upper_case_globals, non_camel_case_types, non_snake_case)] (or use bindgen’s .raw_line("...") to inject the allow into the generated file). Never re-export raw bindings as your crate’s public API.
  • Always wrap, never expose. Build a safe Rust API (convert, slugify, …) over the raw bindings and keep unsafe blocks small with explicit // SAFETY: comments. The two-crate convention — a *-sys crate holding only raw bindings and a sibling crate holding the safe wrapper — is the ecosystem standard (openssl-sys + openssl, libgit2-sys + git2, and so on).
  • Allowlist aggressively to keep generated output small, readable, and fast to compile.
  • Pin layout with the generated assertions (they are on by default) and let derive_default/derive_debug/derive_partialeq add the traits you need rather than hand-writing impls.
  • Use rerun-if-changed for every input file so incremental builds stay correct.
  • Prefer the default (constant) enum representation for values that originate in C; reserve .rustified_enum() for enums you fully own.
  • Vendor or feature-gate the C source. The cc crate compiling a checked-in csrc/ keeps builds reproducible; linking a system library instead means emitting cargo:rustc-link-lib=... from build.rs.

Tip: A standalone bindgen command-line tool exists (cargo install bindgen-cli) for quickly previewing what a header generates: bindgen csrc/temperature.h --allowlist-function 'temp_.*'. It is handy for exploration, but for real projects keep generation in build.rs so the bindings always track the header.


A production-flavored wrapper: a C slugify function (the kind you might pull in from an existing C codebase) that turns arbitrary text into a URL-safe slug. It uses a caller-provided output buffer and a sentinel return value for “buffer too small” — a very common C convention that forces us to think about strings and error handling across the boundary.

csrc/slug.h
#ifndef SLUG_H
#define SLUG_H
#include <stddef.h>
/*
* Write a URL-safe slug of `input` into the caller-provided `out` buffer
* (lowercase ASCII letters/digits, runs of other chars collapsed to '-').
* Returns the number of bytes written (excluding the NUL terminator),
* or (size_t)-1 if `out` was too small.
*/
size_t slugify(const char *input, char *out, size_t out_len);
#endif
csrc/slug.c
#include "slug.h"
#include <ctype.h>
size_t slugify(const char *input, char *out, size_t out_len) {
if (out_len == 0) return (size_t)-1;
size_t w = 0;
int prev_dash = 1; /* avoid leading dash */
for (const char *p = input; *p; ++p) {
unsigned char c = (unsigned char)*p;
if (isalnum(c)) {
if (w + 1 >= out_len) return (size_t)-1;
out[w++] = (char)tolower(c);
prev_dash = 0;
} else if (!prev_dash) {
if (w + 1 >= out_len) return (size_t)-1;
out[w++] = '-';
prev_dash = 1;
}
}
while (w > 0 && out[w - 1] == '-') w--; /* trim trailing dash */
out[w] = '\0';
return w;
}
build.rs
use std::env;
use std::path::PathBuf;
fn main() {
cc::Build::new()
.file("csrc/slug.c")
.include("csrc")
.compile("slug");
println!("cargo:rerun-if-changed=csrc/slug.h");
println!("cargo:rerun-if-changed=csrc/slug.c");
let bindings = bindgen::Builder::default()
.header("csrc/slug.h")
.allowlist_function("slugify")
.generate()
.expect("unable to generate bindings");
let out = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out.join("bindings.rs"))
.expect("couldn't write bindings");
}

bindgen emits this declaration for the function (note the C char * becomes *const/*mut ::std::os::raw::c_char):

unsafe extern "C" {
pub fn slugify(
input: *const ::std::os::raw::c_char,
out: *mut ::std::os::raw::c_char,
out_len: usize,
) -> usize;
}

The safe wrapper marshals a Rust &str into a C string, provides an output buffer, decodes the sentinel return value into a Result, and reads the result back as UTF-8:

src/lib.rs
#![allow(non_upper_case_globals, non_camel_case_types, non_snake_case)]
mod ffi {
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
}
use std::ffi::{CStr, CString};
/// Errors that the safe `slugify` wrapper can return.
#[derive(Debug, PartialEq, Eq)]
pub enum SlugError {
/// The input contained an interior NUL byte (not a valid C string).
InteriorNul,
/// The output buffer was too small for the result.
BufferTooSmall,
}
/// Turn arbitrary text into a URL-safe slug, delegating to the C `slugify`.
pub fn slugify(input: &str) -> Result<String, SlugError> {
// 1. Marshal the &str into a NUL-terminated C string.
let c_input = CString::new(input).map_err(|_| SlugError::InteriorNul)?;
// 2. Provide an output buffer the C side will fill. A slug is never longer
// than the input, so input length + 1 (for the NUL) is always enough.
let mut out = vec![0u8; input.len() + 1];
// SAFETY: `c_input` is a valid NUL-terminated string; `out` points to
// `out.len()` writable bytes; `slugify` only writes within that span and
// NUL-terminates. Both borrows outlive the call.
let written = unsafe {
ffi::slugify(
c_input.as_ptr(),
out.as_mut_ptr() as *mut std::os::raw::c_char,
out.len(),
)
};
// The C API signals failure with (size_t)-1, which maps to usize::MAX.
if written == usize::MAX {
return Err(SlugError::BufferTooSmall);
}
// SAFETY: on success the C function wrote `written` bytes plus a NUL,
// so `out` is a valid C string.
let slug = unsafe { CStr::from_ptr(out.as_ptr() as *const std::os::raw::c_char) };
Ok(slug.to_string_lossy().into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_slug() {
assert_eq!(slugify("Hello, World!").unwrap(), "hello-world");
}
#[test]
fn collapses_and_trims() {
assert_eq!(slugify(" Rust & TS: FFI ").unwrap(), "rust-ts-ffi");
}
#[test]
fn rejects_interior_nul() {
assert_eq!(slugify("a\0b"), Err(SlugError::InteriorNul));
}
}

Running the tests gives this real output:

$ cargo test --quiet
running 3 tests
...
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

Notice how all the FFI danger — the raw pointers, the NUL handling, the sentinel decoding — is contained inside one function. Callers and tests work entirely with &str, String, and Result. The CString/CStr types that bridge Rust strings and C strings are covered in calling-c.md.


Within this guide:


Difficulty: Beginner

Objective: See the difference allowlisting makes and keep generated output minimal.

Instructions: Take the temperature.h from this page and write a build.rs that generates bindings without any allowlist_* calls, then build. Observe how much extra code (e.g. items pulled in from <stdint.h>) appears in $OUT_DIR/bindings.rs. Then add allowlist_function("temp_.*") and the two allowlist_type calls and compare. Which approach would you ship?

Solution
// build.rs — the allowlisted version (what you should ship).
use std::env;
use std::path::PathBuf;
fn main() {
cc::Build::new()
.file("csrc/temperature.c")
.include("csrc")
.compile("temperature");
println!("cargo:rerun-if-changed=csrc/temperature.h");
println!("cargo:rerun-if-changed=csrc/temperature.c");
let bindings = bindgen::Builder::default()
.header("csrc/temperature.h")
.allowlist_function("temp_.*")
.allowlist_type("Reading")
.allowlist_type("TempUnit")
.rustified_enum("TempUnit")
.generate()
.expect("unable to generate bindings");
let out = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out.join("bindings.rs"))
.expect("couldn't write bindings");
}

Without the allowlist calls, bindgen emits declarations for everything reachable from the header, including types dragged in by #include <stdint.h>. The allowlisted version emits only TempUnit, Reading, temp_convert, and temp_conversion_count. Always ship the allowlisted version: smaller output, faster compiles, and a clearer contract.

Difficulty: Intermediate

Objective: Build a safe API over a generated binding and add a Rust ergonomic feature.

Instructions: Using the temperature library, add a safe function convert_rounded(value: f64, from: Unit, to: Unit, decimals: u32) -> f64 that calls the C temp_convert through the bindings and rounds the result to decimals decimal places. Keep the unsafe call confined to a wrapper and write a test asserting convert_rounded(98.6, Unit::Fahrenheit, Unit::Celsius, 2) == 37.0.

Solution
// src/main.rs (additions)
/// Convert and round to `decimals` decimal places.
pub fn convert_rounded(value: f64, from: Unit, to: Unit, decimals: u32) -> f64 {
let raw = convert(value, from, to); // convert() already wraps the unsafe call
let factor = 10f64.powi(decimals as i32);
(raw * factor).round() / factor
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rounds_to_two_places() {
assert_eq!(convert_rounded(98.6, Unit::Fahrenheit, Unit::Celsius, 2), 37.0);
}
}
$ cargo test --quiet
running 1 test
.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

The key idea: convert_rounded never writes unsafe itself — it builds on the already-safe convert, so the unsafe surface stays as small as possible.

Exercise 3: Choose the right enum representation

Section titled “Exercise 3: Choose the right enum representation”

Difficulty: Advanced

Objective: Understand why .rustified_enum() can be unsound for values that come from C, and pick a safe representation.

Instructions: Suppose a C function TempUnit detect_unit(const char *label); returns a TempUnit parsed from user input, and a buggy or hostile C implementation could return 99 for unknown labels. Explain why binding this enum with .rustified_enum("TempUnit") and matching on the result is undefined behavior, and rewrite the bindgen configuration to make it sound. Show how the safe wrapper would validate the returned value.

Solution

A Rust enum may only ever hold one of its declared discriminants — holding any other bit pattern is instant undefined behavior. With .rustified_enum("TempUnit"), detect_unit is typed to return the Rust enum TempUnit, so if C returns 99 you have already constructed an invalid enum before you even match on it. The cure is to not rustify enums whose values originate in C; use the default (or .newtype_enum()), receive the value as a plain integer, and validate it:

// build.rs — drop .rustified_enum() and use newtype_enum instead.
let bindings = bindgen::Builder::default()
.header("csrc/temperature.h")
.allowlist_function("temp_.*")
.allowlist_function("detect_unit")
.allowlist_type("Reading")
.allowlist_type("TempUnit")
.newtype_enum("TempUnit") // a #[repr(transparent)] wrapper, not a Rust enum
.generate()
.expect("unable to generate bindings");
// src/main.rs — validate the C-provided value before trusting it.
pub fn detect_unit(label: &str) -> Option<Unit> {
let c_label = std::ffi::CString::new(label).ok()?;
// SAFETY: `c_label` is a valid NUL-terminated string for the call's duration.
let raw = unsafe { ffi::detect_unit(c_label.as_ptr()) };
match raw {
ffi::TempUnit::TEMP_CELSIUS => Some(Unit::Celsius),
ffi::TempUnit::TEMP_FAHRENHEIT => Some(Unit::Fahrenheit),
ffi::TempUnit::TEMP_KELVIN => Some(Unit::Kelvin),
_ => None, // an unknown discriminant like 99 is handled, not UB
}
}

With newtype_enum, TempUnit is a #[repr(transparent)] struct wrapping an integer plus the known-variant constants, so receiving 99 is a perfectly valid value that your match rejects safely — no undefined behavior, ever.