Introduction
cheadergen generates accurate C headers for Rust libraries that expose a C-compatible API.
cheadergen provides:
- Multi-crate support. One C header per crate, with
cross-crate
#includes wired up automatically. - Compiler-accurate type analysis. Type information comes from
rustdoc-json, so the generated output mirrors what the Rust compiler actually sees. - Macro-aware. Items defined by declarative or procedural macros are picked up automatically.
cheadergen is an alternative to
cbindgen. Check out our
comparison page
for more details.
What it does
You write Rust:
#![allow(unused)]
fn main() {
#[repr(C)]
pub struct Point {
pub x: f64,
pub y: f64,
}
#[unsafe(no_mangle)]
pub extern "C" fn distance(a: Point, b: Point) -> f64 {
((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt()
}
}
cheadergen produces a header your C code can consume:
typedef struct {
double x;
double y;
} Point;
double distance(Point a, Point b);
New to C/Rust interoperability?
If you’ve never written extern "C" in Rust before, the
FFI chapter of the Rust Nomicon
is a good primer on the building blocks cheadergen relies on: the C
calling convention, #[no_mangle] for stable symbol names, #[repr(C)]
for predictable struct layout, and the rules around passing data across
the boundary.
Read through it first if any of those terms feel unfamiliar: the
rest of this guide assumes you know what they do, and focuses on how
cheadergen can support your mixed C/Rust projects.
Author
cheadergen (C Header Generator) is built and maintained by Luca Palmieri.
User guide structure
This guide is structured as follows:
- Getting started. If you’re new, start here. Install the CLI, then walk through the Quickstart.
- Foundations. The load-bearing concepts you’ll meet using cheadergen: target packages, item annotations, and global configuration.
- How it works. The internals: the processing pipeline and generics and monomorphization.
- What you get. The shape of the generated output: anatomy of a generated header and bundled vs partitioned output.
- Limits and alternatives.
Comparison with
cbindgen, whatcheadergencan’t do, and C++ support. - How-to guides.
Recipes for common tasks:
wiring
cheadergeninto Cargo, generating headers across a workspace, migrating fromcbindgen, and more. - API references on docs.rs.
The canonical references for the options exposed by the
#[cheadergen::config(...)]attribute and thecheadergen.tomlconfig file.
Install cheadergen
cheadergen ships as a single binary called cheadergen.
Since cheadergen relies on rustdoc-json
for header generation, it requires a specific nightly toolchain to be installed via rustup.
Pick whichever installation method fits your environment best.
Prebuilt binaries
On macOS or Linux:
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/LukeMathWalker/cheadergen/releases/download/0.2.4/cheadergen_cli-installer.sh \
| sh
The installer downloads the prebuilt binary for your platform and drops it into
~/.cargo/bin (or, if Cargo isn’t installed, ~/.local/bin).
On Windows, via Powershell:
powershell -ExecutionPolicy Bypass -c "irm https://github.com/LukeMathWalker/cheadergen/releases/download/0.2.4/cheadergen_cli-installer.ps1 | iex"
Installers download prebuilt binaries from our GitHub releases. Each archive ships with a sibling .sha256 file you can use to verify the
download.
From crates.io
cargo install --locked cheadergen_cli
cheadergen_cli is the binary crate; the installed executable is named
cheadergen. If you have cargo-binstall
it will fetch the prebuilt artifacts:
cargo binstall cheadergen_cli
Nightly toolchain
cheadergen invokes cargo doc --output-format=json, which is only
available on nightly. You don’t need to make nightly your default toolchain:
cheadergen calls a pinned nightly via cargo +<toolchain>.
If the pinned nightly isn’t installed when cheadergen runs, header generation
will fail with an error that explains how to install the missing toolchain.
If you want to install it eagerly, run:
rustup install nightly-2026-06-04 -c rust-docs-json
Verify the install
cheadergen --help
cheadergen generate --help
Both commands should print usage information without errors. You’re ready for the Quickstart.
Quickstart
This walkthrough takes you from an empty directory to a generated C header in
about five minutes. It assumes you have already
installed cheadergen.
1. Create a tiny Rust library
cargo new --lib distance
cd distance
Edit Cargo.toml so the crate compiles as a C-compatible static library:
[package]
name = "distance"
version = "0.1.0"
edition = "2024"
[lib]
# 👇 Change the crate type from the default `rlib` to `staticlib`
crate-type = ["staticlib"]
Replace src/lib.rs:
#![allow(unused)]
fn main() {
#[repr(C)]
pub struct Point {
pub x: f64,
pub y: f64,
}
#[unsafe(no_mangle)]
pub extern "C" fn distance(a: Point, b: Point) -> f64 {
((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt()
}
}
Why these annotations?
#[unsafe(no_mangle)]keeps the symbol name intact so a C linker can resolve it.extern "C"makes the function use the C calling convention so a C caller passes arguments and reads the return value the way Rust expects. Together they makedistancecallable from C. Without them,cheadergenhas no signal that you intend the function to be reachable from C and skips it.Same reasoning applies to
Point:#[repr(C)]guarantees that the struct’s layout matches a C struct. Without it,cheadergenwould refuse to emit a C definition for it.
2. Generate the header
cheadergen generate --output-dir include
You should see something like:
Generating headers for 1 crate(s) using toolchain `nightly-...`
Successfully loaded rustdoc JSON for `distance`: root module `distance`
`distance`: 1 function(s), 0 static(s), 0 constant(s)
Wrote target header: include/distance.h
3. Read the output
In include/distance.h you should see something along the lines of:
/* WARNING: this file was auto-generated by
* cheadergen from `distance`.
* Do not edit it manually. */
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
typedef struct {
double x;
double y;
} Point;
double distance(Point a, Point b);
That’s the whole loop. To understand how the generated C header is structured (and how to customise it), read Anatomy of a generated header.
Where to go next
- Customise emission via
cheadergen.toml. - Customise how Rust items are exposed to C via
#[cheadergen::config(...)]. Start from the annotations mental model. - Wire header generation into your build system with Integrate with Cargo.
Target packages
When you run cheadergen generate, the first thing it has to figure out is
which Rust packages to generate headers for. That set is called the
target packages.
This page explains what a target package is, how the set is computed, and
why it matters when you reach for #[cheadergen::config(export)].
Target packages vs dependency packages
A target package is a package that cheadergen treats as a
header-emission target. Its extern "C" functions, #[no_mangle] statics,
and items annotated with #[cheadergen::config(export)] all get pulled into
the output.
A dependency package is anything else cheadergen reaches while
following type references: workspace siblings of a target, third-party
crates from crates.io, the standard library. Its types may still appear in
the output (because a target’s signature uses them), but its extern "C"
items and #[cheadergen::config(export)] annotations are ignored.
The distinction is local to a single cheadergen generate invocation. The
same crate can be a target in one run and a dependency in another, depending
on the flags you pass.
How the target set is computed
cheadergen generate accepts three kinds of selectors. They combine in the
order below; the final set is what gets fed to the rest of the pipeline.
--input-dir (repeatable)
cheadergen generate --output-dir include --input-dir <PATH>
<PATH> must be a directory: every workspace member whose manifest
lives under that directory is selected. Pointing at the workspace root
selects every workspace member; pointing at a subdirectory scopes the
selection to the crates inside it.
Pass --input-dir multiple times to combine several directories in a single
run. Each path must live inside the workspace rooted at the current working
directory; a path outside that workspace, or one that contains no workspace
members, is an error.
--package / -p (repeatable)
cheadergen generate --output-dir include -p alpha -p beta
Adds workspace members by name on top of whatever --input-dir selected.
Each name must match a workspace member; unknown names produce an error.
If you pass --package flags without any --input-dir, the directory-based
step is skipped entirely; only the named packages end up in the target set.
--exclude (repeatable)
cheadergen generate --output-dir include --exclude internal-tools
Subtracts workspace members by name from the set produced by the steps above. Excluding a package that isn’t in the set is an error.
Library-only filter
After all three selectors run, cheadergen drops any package without a
library build target: cheadergen can only generate headers for library
crates. If you explicitly named a binary-only package via -p, that’s an
error; if a binary-only package was picked up implicitly (via --input-dir
or the default), it’s silently skipped with a warning.
Default
With no --input-dir, no --package, and no --exclude, cheadergen mirrors
Cargo’s default:
- If the current working directory sits inside a workspace member’s directory, only that member becomes a target. (When several candidates contain cwd — e.g. a hybrid workspace where the root is a package and has nested members — the closest one wins.)
- Otherwise the workspace’s
[workspace] default-membersare used. For a virtual workspace that expands to every member; for a non-virtual workspace it’s the root package.
So cheadergen generate run from crates/alpha/ targets only alpha,
while the same command run from the workspace root targets every member
(or whichever default-members configures).
Why this matters for #[cheadergen::config(export)]
#[cheadergen::config(export)] only takes effect when the defining package
is a target. Annotations on a non-target package are silently ignored.
In practice that means:
- If a type defined in a third-party crate carries an
exportannotation upstream,cheadergenwon’t pick it up; that crate isn’t in your target set. - If a type defined in a workspace sibling carries
export, you need to make sure that sibling is in the target set (via--input-dir, an explicit-p, or a cwd that resolves to it). - If
cheadergen“doesn’t see” an annotation you added, the most common cause is that the defining package isn’t a target in this invocation.
Items that don’t rely on #[cheadergen::config(export)] for inclusion (extern "C" functions, #[no_mangle] statics) follow the same rule: they
only become entry points when they live in a target package.
A worked example
Consider this workspace:
my-workspace/
├── Cargo.toml # [workspace] members = ["alpha", "beta", "core", "cli"]
├── alpha/ # library, has extern "C" functions
├── beta/ # library, has extern "C" functions
├── core/ # library, shared types only — no extern "C"
└── cli/ # binary crate
Different invocations produce different target sets:
| Invocation | Target packages | Notes |
|---|---|---|
cheadergen generate … (run from the workspace root) | alpha, beta, core | cli dropped (binary-only, warning). |
cheadergen generate … (run from alpha/) | alpha | Default. core’s types may still appear if alpha uses them. |
cheadergen generate … --input-dir alpha | alpha | Same selection, explicit form; works from any cwd inside the workspace. |
cheadergen generate … -p alpha -p beta | alpha, beta | core is not a target; any #[cheadergen::config(export)] in core is ignored. |
cheadergen generate … --exclude cli (from the workspace root) | alpha, beta, core | Same as the default; explicit --exclude avoids the binary warning. |
See also
- The processing pipeline. What happens after the target set is resolved.
- Bundled vs partitioned output. How the target set interacts with the choice of output mode.
- Generate headers for a Cargo workspace. Recipes for common workspace shapes.
Item annotations
cheadergen uses a procedural macro to customize header generation
on a per-item basis: #[cheadergen::config(...)].
#[cheadergen::config(...)] can be applied to structs, enums, unions, type
aliases, functions, constants or statics. It’s an item-level attribute.
From time to time, you may need field-level configuration. That’s done via
#[cheadergen(...)], the member-level attribute.
It only takes effect when the parent item also carries #[cheadergen::config(...)]; without it, the proc macro
never fires and the inner attribute is ignored.
Both attributes accept a comma-separated list of directives, just like serde
or clap:
#![allow(unused)]
fn main() {
#[cheadergen::config(export, opaque, rename = "Handle")]
pub struct InternalHandle { /* ... */ }
}
This page sketches the categories of directive so you know what’s available and where to look. The attribute reference on docs.rs is the authoritative list, including the validation rules.
The five categories
1. Inclusion control
Decides whether an item shows up in the header at all.
| Directive | Meaning |
|---|---|
export | Force inclusion of a type that isn’t reachable from any extern "C" item. |
skip | Exclude an item even if FFI traversal would normally pull it in. |
export is meaningful only on types. Functions and statics with
extern "C" + #[no_mangle] are auto-included; applying export to them is
a compile error.
export is idempotent: applying it to a type that is already reachable from
FFI has no inclusion effect, but the other directives on the same attribute
still apply.
2. Shape
Changes how a type or field is rendered in C.
| Directive | Where | Meaning |
|---|---|---|
opaque | type | Emit as a forward declaration (typedef struct Foo Foo;) when included. |
field_names(a, b, …) | tuple struct | Assign C field names to positional fields. |
bitfield = N | field | Emit as a C bitfield of width N. |
const_ptr | field | Qualify the resulting C pointer as const T *. |
opaque on its own doesn’t force inclusion: it only changes the rendering
if the type is included for some other reason. Combine it with export if
you also want it to appear regardless of FFI reachability.
3. Naming
Renames identifiers as they cross the Rust/C boundary.
| Directive | Where | Meaning |
|---|---|---|
rename = "CName" | type, function, static, field, variant | Override the C name. |
rename_all = "..." | struct, enum, union | Bulk-rename fields or variants using a casing rule. |
rename_all_fields = "..." | enum (with struct variants) | Bulk-rename fields inside the enum’s struct variants. |
prefix_with_name | enum | Prefix variant names with the enum name (e.g. Status_Ok). |
The casing rules accept the same string literals as serde:
"camelCase", "PascalCase", "snake_case", "SCREAMING_SNAKE_CASE". A
per-field/per-variant rename = "..." always beats the bulk
rename_all/rename_all_fields rule.
4. Validation
The proc macro rejects nonsensical combinations at compile time. A few of the more useful checks:
exportandskipon the same item → error.exporton a function or static → error.opaqueon a function, static, or constant → error.prefix_with_nameon a non-enum → error.field_nameson a non-tuple struct → error.rename_all_fieldson an enum without any struct variants → error.- Unknown casing strings, unknown directives → error.
Errors fire when you compile your Rust crate, not when you run cheadergen —
so you find out as part of your normal cargo build loop.
5. Type-aware runtime checks
A few directives can’t be fully validated by the proc macro because they
depend on cheadergen’s type lowering:
const_ptrrequires that the field’s resolved C type is a pointer (e.g.*mut T,NonNull<T>). If lowering produces a non-pointer,cheadergenfails at generation time with a clear error.
How it works under the hood
When the proc macro fires, it rewrites each directive into a
#[diagnostic::cheadergen::...] attribute that can be seen in the JSON output of rustdoc. cheadergen then picks up those
attributes when indexing that crate.
You don’t need to know any of this to use the attributes, but it’s a cool trick that’s worth sharing!
See also
- The
#[cheadergen::config(...)]reference. The canonical list with every directive’s exact semantics.
Global configuration
Beyond CLI flags, cheadergen reads an optional cheadergen.toml. It’s
where you keep project-wide customisation: anything you’d rather not
retype on every invocation, or anything that doesn’t have a CLI flag at all.
This page covers how the file is loaded and what categories of options it
holds. For the exhaustive option-by-option schema, see the canonical
cheadergen.toml reference on docs.rs.
How it’s loaded
There is no auto-discovery. cheadergen only reads a config file when
you point at one explicitly:
cheadergen generate --config cheadergen.toml --output-dir include
Without --config, cheadergen runs with the same defaults as an empty
cheadergen.toml. Every option in the reference has a documented default.
The file’s location on disk doesn’t matter. A workspace can store its
config anywhere convenient (cheadergen.toml at the workspace root,
tools/cheadergen.toml, etc.) and pass the path through --config.
What it can configure
The config file is grouped by scope. At a glance:
- Top-level keys are global emission defaults:
preamble,after_includes,autogen_warning,pragma_once,no_includes,documentation,documentation_style,documentation_length,style,cpp_compat,usize_is_size_t,bundle,sort_by, … [header.<name>]introduces overrides scoped to a single output header (e.g.[header.alpha]only affectsalpha.h). Accepts most top-level keys plusinclude_guard.[package.<name>]introduces overrides scoped to a single Rust package (e.g.[package."my-crate"]). Controlsheader_name,types = "opaque" | "skip",usize_is_size_t, and per-package emission tweaks.[fn],[static],[constant]set the default sort order and emission rules for function declarations, static items, and constant macros.
Each of those subsections has its own page in the reference with the full list of keys and accepted values.
CLI vs config precedence
CLI flags trump the config file: --bundle, --cpp-compat, --style, and
the rest override the corresponding TOML key when both are set. Per-header
sections ([header.<name>]) in turn override the top-level keys. The full
override priority is documented in the
override priority section of the reference.
See also
- Anatomy of a generated header — which sections each option influences.
- Target packages —
[package.<name>]overrides apply regardless of target/dependency status, but a few directives only make sense for target packages. cheadergen.tomlreference — canonical, exhaustive schema.
The processing pipeline
When you run cheadergen generate, your crate flows through six stages before
a single byte of C lands on disk. Understanding the order helps you reason
about why a given item did (or did not!) end up in the generated C header(s).
1. Invoke rustdoc-json
cheadergen invokes cargo +<nightly> doc [...] --output-format=json
for each target package. The Rust compiler does the
heavy lifting: macro expansion, module resolution, type checking, name
resolution.
2. Filter FFI items
cheadergen scans the JSON file emitted by rustdoc to determine which items
are exposed to C:
- Functions annotated with
extern "C"and#[no_mangle]. - Statics annotated with
#[no_mangle]. - Anything that explicitly opted into C visibility via
#[cheadergen::config(export)].
Caution!
cheadergenonly picks up items annotated via#[cheadergen::config(export)]when the defining package is a target package. Annotations in non-target packages (workspace siblings you didn’t select, third-party dependencies, the standard library) are silently ignored.
3. Compute the crate closure
cheadergen then tries to determine the crate closure: the set of Rust types
that are reachable from any of the items collected by the previous step (e.g. a struct used
as input parameter in one of your extern "C" functions).
Keep in mind that a function signature in your crate can reference a type defined in a
different crate (a workspace member, a dependency from crates.io, etc.).
cheadergen walks each external reference and pulls in the defining crate’s
rustdoc JSON on demand (recursively) until every reachable type has a
definition.
This is also why dependency types can end up in your generated header at all, and why the partitioned output mode gives each defining crate its own header file.
The JSON files for third-party dependencies are cached on disk in
cheadergen’s cache directory.
Subsequent runs try to reuse them whenever possible.
Tip. You can pre-warm the cache for an entire workspace with
cheadergen cache warm, inspect the cache directory withcheadergen cache show dir, and clear it withcheadergen cache clear. See Manage the rustdoc cache for the dedicated guide.
4. Build the IR
The raw rustdoc-types items are translated into cheadergen’s own intermediate
representation (IR). The IR is closer to C than to Rust: pointers, primitives,
tagged unions and bitfields are all explicit, and naming concerns
(rename, prefix_with_name, casing rules) are resolved here.
5. Transform standard types
A handful of well-known Rust types are special-cased so the resulting C is idiomatic:
| Rust input | C output | Notes |
|---|---|---|
Box<T> | T * | |
NonNull<T> | T * | Becomes const T * if the field is annotated with #[cheadergen(const_ptr)]. |
Option<&T> / Option<&mut T> | const T * / T * | |
Option<Box<T>> / Option<NonNull<T>> | T * | Null-pointer optimization; same shape as the inner pointer. |
Option<fn(...)> | fn(...) | Function pointers are inherently nullable. |
Option<W> where W is #[repr(transparent)] wrapping an NPO type | W | Null-pointer optimization is carried through transparent wrappers. |
ManuallyDrop<T> / UnsafeCell<T> / MaybeUninit<T> | T | Zero-cost wrappers are stripped before emission. |
PhantomData<T> / PhantomPinned | (field skipped) | Zero-sized types aren’t emitted. |
usize / isize | uintptr_t / intptr_t | They become size_t / ptrdiff_t if usize_is_size_t is set to true, either globally or per-package. |
std::ffi::c_int, c_char, … | C’s native int, char, … | The full core::ffi / std::ffi C primitive aliases are recognised. |
6. Emit
The final IR is rendered into one or more C headers. The exact shape (the
order of sections, what gets #included, whether you get one file or multiple) is
controlled by your cheadergen.toml and CLI flags. See
Anatomy of a generated header and
Bundled vs partitioned output for more details.
Generics and monomorphization
Rust generics don’t exist in C. Wrapper<i32> and Wrapper<f32> are
treated as distinct C types: they have different field types, different sizes,
different layouts. cheadergen resolves this by monomorphizing every
concrete instantiation it sees into a separate, concretely-named C type.
This page explains the naming convention, the placement rule, and the duplicate-definition guards you’ll see in partitioned output.
Naming
A monomorphized instantiation gets a C name built from the generic’s name and the type parameters:
#![allow(unused)]
fn main() {
#[repr(C)]
pub struct Wrapper<T> {
pub value: T,
}
#[unsafe(no_mangle)]
pub extern "C" fn unwrap_i32(w: Wrapper<i32>) -> i32 { w.value }
#[unsafe(no_mangle)]
pub extern "C" fn unwrap_f32(w: Wrapper<f32>) -> f32 { w.value }
}
typedef struct {
int32_t value;
} Wrapper_i32;
typedef struct {
float value;
} Wrapper_f32;
int32_t unwrap_i32(Wrapper_i32 w);
float unwrap_f32(Wrapper_f32 w);
For enums, each instantiation produces a small family of auxiliary types. For example,
#![allow(unused)]
fn main() {
#[repr(C, u8)]
pub enum Either<T> {
Left(T),
Right(i32),
}
}
is rendered on the C side as a tagged union via Either_i32_Tag (the discriminant)
and Either_i32 (the outer struct). All members of a family travel together; see “Placement” below.
Placement
In bundled mode the question is moot: every
monomorphization ends up in the single output header. In partitioned mode
cheadergen has to pick one of (potentially) several headers as the owner.
The rule is: a monomorphized instantiation lives in the consuming crate’s
header, where “consumer” means the crate whose non-generic type or
extern "C" item references the instantiation.
| Scenario | Where Wrapper_i32 lives |
|---|---|
Target A has extern "C" fn foo(w: Wrapper<i32>) | A.h |
Non-target B has struct BStruct { field: Wrapper<i32> }, used by target A | B.h (the earliest consumer in the dependency chain) |
Both B and A use Wrapper<i32> directly | Both, with #ifndef guards (see below) |
Why not the defining crate?
The “obvious” choice (put Wrapper_i32 in the header of the crate that
defines Wrapper<T>) can result in synthetic includes.
If Wrapper<MyStruct> lived in wrapper.h, then wrapper.h would need #include "mystruct.h" even though
the defining crate has no Rust dependency on MyStruct’s crate.
Placing monomorphizations in the consuming crate keeps the include graph faithful to the Rust dependency graph.
Duplicate-definition guards
When multiple consumers use the same instantiation, cheadergen emits the
definition in each consuming crate’s header. To prevent redefinition errors
when those headers are included together, every monomorphized type is wrapped
in #ifndef guards:
#ifndef WRAPPER_I32_DEFINED
#define WRAPPER_I32_DEFINED
typedef struct {
int32_t value;
} Wrapper_i32;
#endif /* WRAPPER_I32_DEFINED */
A C preprocessor sees the first definition, sets the guard, and skips all subsequent copies. Duplicates are harmless.
Single-header runs skip the guards. If only one header is being written in this invocation,
cheadergenomits the#ifndefwrappers — they would just be noise.
Non-generic types are not guarded
Non-generic types live in their defining crate’s header and appear there exactly once, so guards aren’t needed and would just clutter the output.
See also
- Bundled vs partitioned output to understand why partitioned mode needs these rules at all.
Anatomy of a generated header
Every header cheadergen writes follows the same fixed section order. This page
walks through that order top-to-bottom and points at the
cheadergen.toml
options that influence each section.
Knowing the structure helps when reading a generated header and if you’re trying to customise the output.
Options like preamble, [header.<name>].include_guard, and [package.<name>]
live in cheadergen.toml. See Global configuration for how
the file is discovered and loaded; this page focuses on which section
each option affects.
The sections, in order
| # | Section | Configured by |
|---|---|---|
| 1 | Preamble (verbatim text) | preamble |
| 2 | Include guard or #pragma once | [header.<name>].include_guard, pragma_once |
| 3 | Autogen warning | autogen_warning |
| 4 | Standard <...> includes | no_includes, usize_is_size_t (adds <stddef.h>) |
| 5 | User includes | includes |
| 6 | Dependency-crate #includes | partitioned mode only (automatic) |
| 7 | After-includes (verbatim text) | after_includes |
| 8 | CHEADERGEN_ALIGNED(n) macro | emitted automatically if any type has #[repr(C, align(N))] |
| 9 | Type definitions (structs, unions, enums, typedefs) and their associated constants | documentation, documentation_style, documentation_length, style, sort_by, [package.<name>] types, usize_is_size_t |
| 10 | Free const items as #define macros | [constant].sort_by, constants opt-in flags |
| 11 | extern "C" { … } open brace | cpp_compat (C-only) |
| 12 | static declarations | [static].sort_by |
| 13 | Function declarations | [fn].sort_by |
| 14 | extern "C" close brace | cpp_compat |
| 15 | Include guard close | include_guard |
| 16 | Trailer (verbatim text) | trailer |
Where this lives in the code. The emission order is defined in
cheadergen_cli/src/codegen.rs(generate_c_header). If a discrepancy ever creeps in between this page and the source, the source wins — please open an issue.
A worked example
Given this Rust crate:
#![allow(unused)]
fn main() {
#[repr(C)]
pub struct Foo {}
impl Foo {
#[cheadergen::config(export)]
pub const U8: u8 = 1;
#[cheadergen::config(export)]
pub const GREETING: &str = "hello world";
}
#[unsafe(no_mangle)]
pub extern "C" fn root(x: Foo) {}
}
cheadergen generate -l c -o include produces:
/* [3] WARNING: this file was auto-generated by cheadergen from `demo`. Do not edit it manually. */
/* [4] Standard includes */
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
/* [9] Type definition, with its associated constants attached */
typedef struct {
} Foo;
#define Foo_U8 1
#define Foo_GREETING "hello world"
/* [13] Function declaration */
void root(Foo x);
(The bracketed numbers correspond to the section table above; cheadergen itself
doesn’t emit them.)
Notes on individual sections
Preamble & trailer (1, 16)
Verbatim text. Useful for license blocks, project-wide banners, or compiler
pragmas that aren’t part of cheadergen’s vocabulary. Both are simple
string-substitution, cheadergen does not parse what you put in them.
Include guard vs #pragma once (2, 15)
If [header.<name>].include_guard = "FOO_H" is set, you get an
#ifndef/#define/#endif triple wrapping the file. If pragma_once = true
is set, you get #pragma once. The two are independent, you can use both or
neither.
Autogen warning (3)
By default cheadergen synthesizes a warning that names the crate the header
came from (workspace-relative path for workspace members, name@version
otherwise). Set autogen_warning = "" to suppress it entirely; set it to a
string to override the default.
Standard includes (4)
cheadergen always emits <stdarg.h>, <stdbool.h>, <stdint.h>, and
<stdlib.h> unless you set no_includes = true. <stddef.h> is added only
when any item in the header was lowered with usize_is_size_t = true (because
that flag introduces size_t and ptrdiff_t).
Dependency includes (6)
In partitioned mode the dependency crate that defines a
referenced type gets its own header file, and the consuming header gets a
matching #include "dep.h". You don’t need to configure this manually in cheadergen.toml,
cheadergen computes the set of includes based on actual type usage.
Type definitions (9)
The heart of the header. Each #[repr(C)] struct/enum/union (or anything
forced in via #[cheadergen::config(export)]) becomes a definition here.
Associated consts attached to a type are emitted immediately after the type
they belong to as #defines.
A topological sort guarantees that by-value type dependencies are defined
before their users; for ties, sort_by controls the secondary order.
The extern "C" block (11, 14)
C++ consumers need an extern "C" block to disable name mangling. cheadergen
emits one around functions and statics when the cpp_compat option (or
--cpp-compat CLI flag) is set on a C header.
Per-header configuration
Sections 1–16 can all be tuned globally or per individual output header via the
[header.<name>] section in cheadergen.toml. CLI flags (such as
--cpp-compat, --bundle) trump both. The full override priority is
documented in the
config reference.
Bundled vs partitioned output
cheadergen has two output modes: bundled and partitioned
Bundled emits a self-contained header for a single target package, with every reachable dependency type inlined. It only handles one target per run.
Partitioned (the default) generates a C header for each defining crate,
with cross-crate references wired up automatically via #include directives.
Use partitioned when you have multiple target packages, or when you want
shared types to live in their own header.
This page explains the model, the trade-offs, and the knobs you can turn.
The two modes at a glance
| Bundled | Partitioned | |
|---|---|---|
| CLI flag | --bundle | (default) |
| Targets per run | Exactly one | One or more |
| Files produced | A single combined header | One header per defining crate |
| Dependency types | Inlined into the output | Defined once, included from consumers |
| Best for | Single-crate libraries that ship a single header artifact | Workspaces with shared FFI types |
Bundled mode
Bundled mode handles one target package per run. Pass --bundle (or set
bundle = true in cheadergen.toml) with exactly one
target package and cheadergen produces a single
self-contained header. Every type the target references (whether it’s
defined in the target itself, in a workspace member, or in a crates.io
dependency) is inlined into that output file.
If you select more than one target package with --bundle, cheadergen
errors out: two bundles built from overlapping dependency graphs would
duplicate the shared types in a way C can’t tolerate, and the tool refuses
to produce colliding output silently.
This is the right choice when:
- You ship exactly one C-facing library.
- You want a single artifact your downstream consumers can drop into their build with no questions.
- You’re migrating from
cbindgenand want output shape parity (cbindgen’s defaults match this).
Partitioned mode
Without --bundle, cheadergen treats types as belonging to their defining
crate and produces one header per crate that contributes types. Cross-crate
references are wired up automatically:
- If target crate
AusesBTypeby value,A.hgets#include "b.h"andB.hgets the full definition ofBType(and any other typesAactually uses fromB). - Crates that contribute only types (no
extern "C"of their own) get a “types-only” header, with no function declarations. - Types from crates the target doesn’t actually use don’t appear at all. Partitioning is demand-driven.
Include vs forward-declare
The decision is per dependency crate, not per type:
- If any type from crate
Bis used by-value inA.h,A.hemits#include "b.h". - If every
Btype used inA.his referenced only behind a pointer,A.hemits a forward declaration (typedef struct BType BType;) and no#include.
This rule keeps the include graph shallow: cheadergen never both includes and
forward-declares from the same dependency.
Opaque packages stay inline
When
[package.<name>]
types = "opaque"`
is used, the package’s types are forward-declared in each consuming header. No header file is generated for the opaque package.
Skipped packages don’t generate anything
When
[package.<name>]
types = "skip"`
is used, cheadergen emits no header for the
package and assumes you’ll supply your own (typically via includes in
cheadergen.toml). Use this for packages whose C headers come from somewhere
outside cheadergen’s control.
Generic types in partitioned mode
Wrapper<i32> doesn’t belong to the crate that defines Wrapper<T>, it
belongs to whichever crate consumes the instantiation. This rule avoids
synthetic cross-crate include dependencies and circular includes. See
Generics and monomorphization for the
full treatment.
The partitioned-only CLI flags
These flags only apply when generating partitioned output (cheadergen rejects
them in combination with --bundle):
--prune-orphans
After generation, delete any *.h / *.hpp files in --output-dir that
cheadergen didn’t write in this run. Useful in CI to catch stale headers from
removed types or packages. Without --prune-orphans, those files just sit
there.
--skip-empty
Skip writing headers that would otherwise contain no declarations (no types,
constants, statics, or functions). Combined with --prune-orphans this
ensures empty-by-deletion headers are cleaned up rather than rewritten as
empty stubs.
Choosing between modes
Start with partitioned (the default). Switch to bundled if:
- You’re packaging a single shared library and the downstream contract is “one
header, one
.so”. - The crate has no workspace siblings that would also need their own headers.
- You’re migrating an existing
cbindgenpipeline and want a small diff against the original output.
Stay on partitioned when:
- You have multiple FFI-facing crates that share types and would otherwise duplicate them.
- You want changes to a shared type to flow through one canonical header rather than appearing in N independent copies.
- You’re building a workspace-wide SDK where consumers may pick and choose which headers they need.
Comparison with cbindgen
cbindgen is the established tool for
generating C/C++ headers from Rust. cheadergen is a younger alternative,
built on different foundations.
This page explains where they differ and how to decide which one fits your project.
Why a a new tool?
cbindgen parses your Rust source files to infer the shape of the C API for
your project. It’s an approach with quite a few downsides:
- Limited module resolution and type-level analysis.
- No macro expansion without resorting to
cargo expandor ad-hoc code paths. - Visibility into other crates is shallow.
In other words, cbindgen can’t tap into the rich view that’s available to the
Rust compiler for a given crate. This limitation can create significant friction in
real world projects; enough friction to build an alternative, cheadergen!
cheadergen relies on the Rust compiler for compile-time reflection, via
rustdoc-json.
That gives it module resolution, type checking, accurate cross-crate
references, and macro expansion for free.
But it’d be dishonest to state that cheadergen is a clear-cut improvement
for all projects: there are some downsides and limitations you should be aware of
before choosing one or the other for your next project.
Behavioural differences
Annotation syntax
cbindgen uses doc-comment annotations:
#![allow(unused)]
fn main() {
/// cbindgen:rename-all=ScreamingSnakeCase
#[repr(C)]
pub enum Status { Ok, Error }
}
cheadergen uses proc-macro attributes that participate in normal Rust
compilation:
#![allow(unused)]
fn main() {
#[cheadergen::config(rename_all = "SCREAMING_SNAKE_CASE")]
#[repr(C)]
pub enum Status { Ok, Error }
}
cheadergen downsides
#[cfg] attributes
cbindgen translates #[cfg(...)] into #ifdef ... directives so a single
header works across platforms. cheadergen can’t do this (see
Limitations).
Constants in array lengths
cbindgen preserves named constants in array-length positions
(int32_t x[FOO]). cheadergen receives the array length as a resolved
literal value from rustdoc JSON, so it emits int32_t x[10] and loses the
constant’s name in that position only.
Doc generation
cheadergen must generate JSON documentation for all packages that appear
in the C API exposed by your Rust project. This implies a cargo doc invocation
that, in most cases, is going to be slower than parsing source code via syn
(the approach taken by cbindgen).
The slowdown is mitigated via a disk-based cache for third-party crates, but
it’s worth calling out as a downside compared to cbindgen.
Furthermore, this requires you have to a nightly toolchain installed,
at least on dev machines. This issue may go away in the future,
once rustdoc-json stabilizes.
When you’d pick cbindgen
- You need
#[cfg]-to-#ifdeftranslation (Firefox-style multi-platform headers). - You need C++-idiomatic output (
enum class, custom constructors,derive-ostream, etc.). - You need Cython output
- You can’t use a
nightlytoolchain for build-time tools.
When you’d pick cheadergen
- You’re on a Cargo workspace with multiple FFI-facing crates and want partitioned headers that share types coherently.
- You value type-checked, IDE-friendly annotations over doc-comment strings.
- Your platform set is narrow enough that the missing
#[cfg]handling isn’t a blocker.
If you’re using cbindgen on a project, and you want to migrate over,
check out our migration guide.
Limitations
cheadergen is young project: there may be defects that we have yet to uncover.
Furthermore, it has some structural limitations due to the approach it has chosen
for its reflection engine.
It’s good to be aware of these limitations ahead of choosing cheadergen for
your next project.
No #[cfg] awareness
cheadergen’s type analysis uses rustdoc-json as its primary source.
rustdoc only emits items for the
current compilation target. Items behind a #[cfg] predicate that evaluates to false
for a given rustdoc invocation simply won’t appear in the generated JSON:
cheadergen won’t see them, at all.
Let’s look at an example:
#![allow(unused)]
fn main() {
#[cfg(target_os = "linux")]
#[unsafe(no_mangle)]
pub extern "C" fn linux_specific() { /* ... */ }
#[cfg(target_os = "windows")]
#[unsafe(no_mangle)]
pub extern "C" fn windows_specific() { /* ... */ }
}
When you run cheadergen on macOS, neither function appears in the generated
header. Run on Linux and only linux_specific appears. Run on Windows and
only windows_specific appears.
If your bindings need to span multiple platforms today, you can must use a workaround:
run cheadergen once per target platform and post-process the outputs into a combined header yourself.
It’s not great, but it’s the best we can suggest at the moment. If your project is #[cfg]-heavy, prefer
cbindgen for now.
Constants names may be lost in translation
cheadergen may omit named constants when generating C headers.
For example, in this case:
#![allow(unused)]
fn main() {
pub const FOO: usize = 10;
#[repr(C)]
struct Foo {
x: [i32; FOO],
}
}
cheadergen receives
the array length from rustdoc JSON as the literal string "10", so it emits
int32_t x[10] and the named FOO constant is no longer used to express
the array length.
No C++ native headers
cheadergen can generate a C++-compatible C header via
cheadergen generate --cpp-compat [...], but the output is a C-shaped header
that happens to compile as C++. There are no C++-idiomatic enums, no operator
overloads, no custom constructors. See C++ support for
the full picture and the roadmap.
See also
- Comparison with
cbindgento determine when to pick one over the other.
C++ support
cheadergen is, as of today, a C header generator. C++ projects can still
consume cheadergen’s output, but only as C header(s) included from C++, not
as a native C++ header with C++-idiomatic features.
What works today: --cpp-compat
The CLI flag is --cpp-compat (equivalently, [c].cpp_compat = true in
cheadergen.toml). It tells cheadergen to wrap function and static
declarations in an extern "C" block guarded by #ifdef __cplusplus:
#ifdef __cplusplus
extern "C" {
#endif // __cplusplus
double distance(Point a, Point b);
#ifdef __cplusplus
} // extern "C"
#endif // __cplusplus
That single addition is enough to make the generated .h file safe to
#include from C++ source code. The C++ compiler now knows the declarations
inside use the C calling convention and C name mangling (none), so linking
against your Rust library “just works.”
The same header is still valid C: the #ifdef guards mean a C compiler sees
the declarations directly.
What’s not (yet) supported: native C++ output
cheadergen cannot produce .hpp files with C++-idiomatic constructs
(e.g. enum class, namespaces, templates, destructors, etc.).
Native C++ support is a feature gap and may be addressed in a future release. There is no scheduled timeline; if you’re interested in it, the issue tracker is the right place to express the use case or contribute.
In the meantime, --cpp-compat is the recommended path for any project that
needs to consume cheadergen output from C++.
See also
- Limitations for a wider view of what
cheadergendoes and doesn’t do today. - Anatomy of a generated header for where the
extern "C"block lands in the generated file. - Comparison with
cbindgen.cbindgendoes ship some C++-idiomatic features thatcheadergendoesn’t.
Integrate with Cargo
This guide shows how to make cheadergen part of your normal Cargo workflow,
so generated headers stay in sync with your Rust source.
Run cheadergen outside cargo build
cheadergen must be invoked as a standalone CLI command, not from a Cargo
build.rs script.
The reason is a Cargo deadlock: a build.rs runs while Cargo holds an
exclusive lock on the target directory for the in-flight build. cheadergen
internally shells out to cargo +<nightly> rustdoc -- --output-format=json,
which tries to acquire the same lock — and waits forever for the build that
spawned it to finish. There’s no way around this from inside build.rs.
The right place for header generation is therefore one level up, in a
command runner (just, make, nix, your CI workflow, etc.) that
sequences cheadergen and cargo build as separate steps.
A minimal justfile:
# Regenerate C headers
headers:
cheadergen generate --output-dir include --prune-orphans
# Build the crate, refreshing headers first
build: headers
cargo build --release
A minimal Makefile:
.PHONY: headers build
headers:
cheadergen generate --output-dir include --prune-orphans
build: headers
cargo build --release
Either way: cheadergen finishes (and releases the target-dir lock) before
cargo build starts.
Idempotent output: mtimes won’t churn
cheadergen calls
write_header_if_changed
for every output file: if the new content is byte-identical to what’s already
on disk, the file is left alone and its mtime is preserved.
Build systems that watch generated headers by mtime (Make, CMake, downstream
cc crates) won’t see spurious rebuilds just because cheadergen ran with no
real change.
Picking an output directory
There’s no universal convention; pick what works for your project.
It’s common to use a directory in the crate root (e.g. /include), committed alongside the Rust source.
Easy for downstream C consumers: they -I path/to/crate/include.
If you’re using partitioned mode, pass
--prune-orphans to keep the output directory clean as types are added and
removed.
Generate headers for a Cargo workspace
cheadergen was designed for Cargo workspaces with multiple FFI-facing
crates that share types. In partitioned mode (the default) the right shape
falls out automatically: each crate that contributes types gets its own
header, and consuming crates #include it.
This guide shows the common patterns. For the mental model of which crates participate in a run and why, see Target packages.
Select multiple target packages
Pass --package multiple times to generate headers for several workspace members
in one invocation:
cheadergen generate \
--output-dir include \
--package alpha \
--package beta
Each --package argument names a workspace member that should produce a “target”
header (one with extern "C" function declarations, not just types).
Alternatively, omit --package and use --input-dir to target every library
member under one or more workspace directories:
cheadergen generate \
--output-dir include \
--input-dir crates/alpha \
--input-dir crates/beta
Pass --input-dir multiple times to combine several directories. Each path
must live inside the workspace rooted at the current directory.
Omitting both --package and --input-dir falls back to a Cargo-style
default: if the current directory sits inside a workspace member
it targets just that member, otherwise it targets the workspace’s
default-members (every member, for a virtual workspace). See
Target packages for the full
rule.
What gets generated
Given two targets alpha and beta that both use a Config type from a
workspace dependency core:
include/
├── alpha.h # target — extern "C" functions and types unique to alpha
├── beta.h # target — extern "C" functions and types unique to beta
└── core.h # types-only — Config and other shared types
- Both
alpha.handbeta.hstart with#include "core.h". Configis defined exactly once, incore.h.core.hhas noextern "C"block —coreisn’t a target, just a type contributor.
A non-target dependency only gets a header if at least one target actually uses its types. External crates (from crates.io) follow the same rules.
Customising header filenames
By default a crate named my-crate produces my_crate.h (hyphens become
underscores). Override the
base name per package with header_name:
# cheadergen.toml
[package."core"]
header_name = "myproject_core"
Now alpha.h and beta.h will #include "myproject_core.h" and the file
on disk is renamed to match.
If a dependency name is ambiguous (multiple versions present), disambiguate
with name@version:
[package."internal-state@1"]
header_name = "state_v1"
Pruning stale output
Add --prune-orphans to clean up any *.h files in the output directory
that this run didn’t write. Useful when you remove types or packages:
cheadergen generate \
--output-dir include \
--prune-orphans \
-p alpha \
-p beta
Without --prune-orphans, removing a crate from the workspace leaves its
header behind as silent stale output.
--prune-orphans only acts on top-level files matching the language
extension (*.h for C); it never touches subdirectories
or unrelated files.
Skipping empty headers
Some non-target crates contribute no concrete C-visible types, only
generic definitions that get monomorphized into their consumers’ headers.
By default cheadergen still writes an (empty) header file for those crates.
Pass --skip-empty to suppress the empty files:
cheadergen generate \
--output-dir include \
--skip-empty \
--prune-orphans
--skip-empty is partitioned-only (it’s rejected with --bundle).
Combining it with --prune-orphans ensures empty-by-deletion headers also
get cleaned up.
See also
- Bundled vs partitioned output — the conceptual model behind the per-crate header layout.
- Generics and monomorphization —
where
Wrapper<i32>actually ends up. - The
cheadergen.tomlreference for the full[package.<name>]schema.
Manage the rustdoc cache
cheadergen invokes cargo +<nightly> doc --output-format=json under
the hood, which is the slowest step of the whole pipeline. To keep iteration
fast, cheadergen keeps the resulting JSON in a per-crate on-disk cache and
reuses it on subsequent runs.
You normally don’t have to touch the cach, it builds up incrementally as
you run cheadergen generate. This page documents the cheadergen cache
subcommands for the cases where you do.
Inspect the cache location
cheadergen cache show dir
Prints the absolute path to the cache directory and exits. The location is platform-specific.
Use this in CI configs to feed the cache directory into your runner’s cache action.
Pre-warm the cache
cheadergen cache warm # Cargo-style default
cheadergen cache warm --input-dir crates/alpha # one workspace directory
cheadergen cache warm --input-dir a --input-dir b # several workspace directories
cheadergen cache warm -p alpha -p beta # specific workspace members by name
cache warm issues a single batched cargo doc -p crate1 -p crate2 ...
invocation, maximising parallelism within cargo.
Clear the cache
cheadergen cache clear
Removes the cache directory and exits.
In practice you rarely need this. Reasons you might:
- The cache directory has grown large enough to matter on a developer machine and you want a fresh start.
- You’re chasing a reproducibility bug and want to confirm the issue isn’t a stale cache entry.
See also
- The processing pipeline for where the cache
fits into a
generateinvocation. - Integrate with Cargo for the broader
workflow
cheadergenslots into.
Migrate from cbindgen
This guide walks through moving an existing cbindgen-using crate to
cheadergen. Before you start, read
Comparison with cbindgen and
Limitations so you know what features won’t
carry over.
1. Install cheadergen
See Install cheadergen.
2. Translate your cbindgen.toml
cheadergen config translate \
--from cbindgen.toml \
--to cheadergen.toml
The translator covers the option set cheadergen supports. Any cbindgen-only
options are reported and need a manual decision.
A spot-check, before:
# cbindgen.toml
language = "C"
include_guard = "MYLIB_H"
pragma_once = true
[parse]
parse_deps = true
after:
# cheadergen.toml
pragma_once = true
[header."mylib"]
include_guard = "MYLIB_H"
cheadergen doesn’t have an equivalent of parse_deps because the rustdoc
pipeline always sees dependency types when they’re reachable from extern "C"
items — the option simply isn’t needed.
3. Rewrite cbindgen annotations
cbindgen reads doc-comment annotations like /// cbindgen:rename-all=...;
cheadergen uses proc-macro attributes. Sweep your source and translate them
using this table:
cbindgen (doc comment) | cheadergen (proc-macro attr) |
|---|---|
/// cbindgen:no-export | #[cheadergen::config(skip)] |
/// cbindgen:ignore | #[cheadergen::config(skip)] |
/// cbindgen:rename-all=ScreamingSnakeCase | #[cheadergen::config(rename_all = "SCREAMING_SNAKE_CASE")] |
/// cbindgen:rename-all=SnakeCase | #[cheadergen::config(rename_all = "snake_case")] |
/// cbindgen:rename-all=CamelCase | #[cheadergen::config(rename_all = "camelCase")] |
/// cbindgen:field-names=[x, y] | #[cheadergen::config(field_names(x, y))] |
/// cbindgen:prefix-with-name | #[cheadergen::config(prefix_with_name)] |
/// cbindgen:bitfield (on a field) | #[cheadergen(bitfield = N)] |
You’ll also need to add the crate dependency:
# Cargo.toml
[dependencies]
cheadergen = "0.2"
The proc-macro lives in the cheadergen crate; you don’t need to depend on
cheadergen_cli (that’s the CLI binary).
Tip. The proc-macro attributes are type-checked at compile time. If you write
#[cheadergen::config(field_namse(x, y))](note the typo),cargo buildwill reject it.cbindgen’s doc-comment annotations would silently do nothing.
4. Regenerate and diff
cheadergen generate --output-dir include --bundle
Pass --bundle to match cbindgen’s “single file per crate” shape. Once you
have output, diff it against your committed cbindgen header:
diff -u old-include/mylib.h include/mylib.h
Expect differences in these places:
#[cfg]-gated items.cheadergensees only the items reachable for the current build target;cbindgentranslates them to#ifdefs. There is no way to make these match — see Limitations.- Array length constants.
[i32; FOO]becomesint32_t x[FOO]incbindgenandint32_t x[10]incheadergen. - Whitespace. Both tools format their output, but the rules differ; small whitespace diffs are normal.
- Doc-comment formatting. Doxygen-style vs plain C-comment choices are
controlled by
documentation_style. - Item ordering may differ between
cheadergenandcbindgen.
If anything else looks unexpectedly different, open an issue with the input and a diff.
5. Decide bundled vs partitioned
--bundle is the closest match to cbindgen’s behaviour. Once the bundled
output is right, consider whether
partitioned mode would suit your project
better:
- Workspaces with multiple FFI-facing crates almost always benefit.
- Single-crate libraries usually don’t.
You can switch at any time; the only difference is the CLI flag (or the
bundle setting in cheadergen.toml).
6. Wire it into your build
If your project ran cbindgen from a build.rs, you’ll need to relocate the
invocation: cheadergen can’t run inside build.rs (it deadlocks against
Cargo’s target-dir lock). Move header generation into a command runner that
sequences cheadergen before cargo build; see
Integrate with Cargo for the recipe.
Common migration pitfalls
- You forgot to add
cheadergento[dependencies]. Without it, the#[cheadergen::config(...)]attribute fails to resolve. Errors surface duringcargo build. - You translated
cbindgen.tomlbut never deleted it. Both tools cheerfully ignore the other’s config. Removecbindgen.toml(or rename it tocbindgen.toml.bakuntil you’re confident) to avoid confusion. - The required nightly isn’t installed. Follow the
installation instructions in the error returned by
cheadergen generateto install the missing toolchain.
Commit or regenerate? Managing generated headers
A perennial question for any code generator: should the generated artifacts live in your repository, or should every consumer rebuild them on demand?
cheadergen works fine either way. This guide lays out the two viable
workflows, what each costs, and what each gives you.
Option 1: commit the generated headers
The headers live in your repo next to the Rust source. A CI check guarantees they stay in sync.
Choose this when:
- You ship a stable C API that downstream consumers depend on.
- Reviewers should see C API changes in pull requests.
- Downstream C consumers don’t (and shouldn’t have to) install
cheadergenor a Rust nightly.
The workflow:
-
Commit the contents of
include/(or wherever your headers live) to version control. -
Add a CI job that regenerates the headers and fails if
git diffis non-empty:cheadergen generate --output-dir include --prune-orphans git diff --exit-code include/--prune-orphansis important: without it, removing a type from the Rust source leaves the old header behind, and CI passes when it shouldn’t. -
Document the regeneration command in CONTRIBUTING.md so contributors can run it locally before opening a PR.
Pros:
- C API changes show up in code review as
+/-lines in real headers. - Consumers can
git cloneand immediately have a usable C interface. - No nightly toolchain on the consumer side.
- Works inside restricted CI / build environments that can’t install
cheadergen.
Cons:
- More churn in PR diffs.
- Easy to forget to regenerate. A CI check to prevent drift is highly recommended.
Option 2: generate on demand
The headers are a build artifact, never committed. Each consumer runs
cheadergen as part of their build.
Choose this when:
- The C API is internal to your team and you control all the consumers.
- The C API isn’t stable yet; reducing PR churn is more valuable than visible C API diffs.
- All consumers already have
cheadergen+ a nightly toolchain.
The workflow:
- Wire
cheadergen generateinto a command runner (just,make, etc.) so it runs as a step before/aftercargo build—see Integrate with Cargo for the reason this can’t live inside abuild.rs. - Add
include/(or wherever the generated headers land) to.gitignore.
Pros:
- Smaller PR diffs.
- No risk of source and generated headers drifting apart, they’re regenerated every build.
Cons:
- Every consumer needs
cheadergen+ nightly Rust on their build machine. - C API changes aren’t visible in code review without extra tooling.
- First-time setup is slower for new contributors.
Hybrid: commit, but only at release time
A workable middle ground for some libraries: don’t commit headers on every
PR, but stamp a regenerated set into the release tarball or GitHub release
artifact at tag time. Contributors don’t deal with header diffs, but
downstream consumers still get headers without needing cheadergen.
The hook is simply a step in your release workflow that runs
cheadergen generate ... and includes the output in the published artifact.