refactor(ffi): generate header via cbindgen

Adopt cbindgen in the build script to keep the C header aligned with the
Rust FFI definitions. Store the patient handle as a void pointer to avoid
layout mismatches and refresh the generated header and repository
guidelines.
This commit is contained in:
2025-09-23 23:16:53 -07:00
parent b1be9d63dc
commit b5b6619118
7 changed files with 289 additions and 86 deletions
+26 -28
View File
@@ -1,41 +1,39 @@
# Repository Guidelines
## Project Structure & Module Organization
- Source: `src/` (crate root `src/lib.rs`). Core modules in `src/organs/`, types in `src/types.rs`, errors in `src/error.rs`, FFI in `src/ffi.rs`.
- Tests: integration tests in `tests/` (e.g., `tests/patient.rs`).
- Examples: `examples/` (Rust usage and tracing demos), plus C FFI example in `examples/c/` with header in `ffi/medicallib.h`.
- Benchmarks: `benches/` (Criterion-based; see `benches/heart.rs`).
- Docs: `README.md`, `ARCHITECTURE.md`, `INSTALL.md`, `MIGRATION.md`.
- `src/` contains the crate root (`src/lib.rs`) and organ modules under `src/organs/`.
- Shared data types live in `src/types.rs`; domain errors in `src/error.rs`.
- FFI layer is `src/ffi.rs` with matching C header in `ffi/medicallib.h`; keep them aligned.
- Integration tests sit in `tests/` (run `cargo test --test patient` to target a file).
- Usage and tracing demos live in `examples/`; the C example is in `examples/c/`.
- Performance benchmarks use Criterion in `benches/` (see `benches/heart.rs`).
- Architectural and setup docs are at the repo root (`README.md`, `ARCHITECTURE.md`, `INSTALL.md`, `MIGRATION.md`).
## Build, Test, and Development Commands
- Build library (debug): `cargo build`
- Build with FFI (shared lib): `cargo build --release --features ffi`
- Run tests: `cargo test` (single test file: `cargo test --test patient`)
- Run examples: `cargo run --example usage` (or `tracing_demo`)
- Benchmarks (Criterion): `cargo bench`
- Lint: `cargo clippy --all-targets -- -D warnings`
- Format: `cargo fmt --all`
- Docs (open): `cargo doc --no-deps --open`
- `cargo build` compiles the library in debug mode for fast iteration.
- `cargo build --release --features ffi` produces the shared library for FFI consumers.
- `cargo test` runs the full test suite; append `--test <name>` for a single integration test.
- `cargo run --example usage` or `cargo run --example tracing_demo` exercises examples.
- `cargo bench` executes Criterion benchmarks; compare results across runs in `target/criterion/`.
- `cargo clippy --all-targets -- -D warnings` enforces lint hygiene before committing.
## Coding Style & Naming Conventions
- Rust style via `rustfmt`; 4-space indentation; wrap at ~100 cols where reasonable.
- Naming: modules/files `snake_case`, types/traits `CamelCase`, functions/vars `snake_case`, constants `SCREAMING_SNAKE_CASE`.
- Public API changes must be intentional; update examples and docs when modifying `src/ffi.rs` or `src/organs/*`.
- Use `cargo fmt --all` (4-space indent, ~100-char lines) before sending patches.
- Modules and files use `snake_case`; types and traits use `CamelCase`; constants are `SCREAMING_SNAKE_CASE`.
- Favor standard library and existing crates; document any `unsafe` with clear safety notes.
- Keep public APIs cohesive within `src/organs/`; update examples/docs when the FFI surface changes.
## Testing Guidelines
- Prefer integration tests in `tests/` named after features (e.g., `basic.rs`, `ffi.rs`).
- Add unit tests near code under `#[cfg(test)]` for small invariants.
- Keep tests deterministic; avoid sleeping/time unless necessary.
- Run `cargo test` locally and ensure `cargo clippy` passes before opening a PR.
- Prefer integration coverage in `tests/`; create files named after features (e.g., `tests/ffi.rs`).
- Add focused unit tests near code under `#[cfg(test)]` for invariants.
- Ensure tests are deterministic; avoid sleeps or external I/O without guards.
- Run `cargo test` and `cargo clippy` locally before opening a PR.
## Commit & Pull Request Guidelines
- Commits: clear, imperative subject (e.g., "Add heart rate update clamp"). Conventional Commits (feat/fix/refactor/docs/test) are welcome.
- PRs: concise description, link issues, note breaking changes, and include tests or examples. Add screenshots only if relevant to docs.
- Write imperative commit subjects (e.g., Add heart rate update clamp”); Conventional Commits are welcome.
- PRs should link related issues, call out breaking changes, and mention new tests or examples.
- Include reproduction steps or screenshots only when they clarify user-facing changes.
## Security & FFI Notes
- FFI: when changing `src/ffi.rs`, update C header `ffi/medicallib.h` and verify `examples/c/ffi_example.c` still builds against the produced `cdylib`.
- Avoid `unsafe` unless essential; document safety contracts.
## Agent-Specific Tips
- Maintain module organization under `src/organs/` and keep APIs cohesive.
- Do not introduce new runtime dependencies without discussion; prefer standard library and existing crates.
- Any change to `src/ffi.rs` must be mirrored in `ffi/medicallib.h` and validated against `examples/c/ffi_example.c`.
- Avoid introducing new runtime dependencies; discuss first if unavoidable.
+4
View File
@@ -35,3 +35,7 @@ opt-level = 3
[profile.dev]
opt-level = 1
[build-dependencies]
cbindgen = '0.26'
+50
View File
@@ -0,0 +1,50 @@
use std::env;
use std::fs;
use std::path::PathBuf;
fn main() {
if env::var_os("CARGO_FEATURE_FFI").is_none() {
return;
}
println!("cargo:rerun-if-changed=src/ffi.rs");
println!("cargo:rerun-if-changed=cbindgen.toml");
let crate_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
let crate_dir_string = crate_dir
.to_str()
.expect("crate directory must be valid UTF-8")
.to_owned();
let header_dir = crate_dir.join("ffi");
fs::create_dir_all(&header_dir).expect("failed to create ffi output directory");
let config_path = crate_dir.join("cbindgen.toml");
let config = if config_path.exists() {
cbindgen::Config::from_file(&config_path)
.unwrap_or_else(|err| panic!("failed to load cbindgen config: {err}"))
} else {
cbindgen::Config::default()
};
let bindings = cbindgen::Builder::new()
.with_config(config)
.with_crate(crate_dir_string)
.generate()
.expect("unable to generate cbindgen bindings");
let mut generated = Vec::new();
bindings.write(&mut generated);
let header_contents = String::from_utf8(generated).expect("generated header was not valid UTF-8");
let header_path = header_dir.join("medicallib.h");
let needs_write = fs::read_to_string(&header_path)
.map(|existing| existing != header_contents)
.unwrap_or(true);
if needs_write {
fs::write(&header_path, header_contents).expect("failed to write medicallib.h");
println!("cargo:warning=Updated ffi/medicallib.h via cbindgen");
}
}
+14
View File
@@ -0,0 +1,14 @@
language = "C"
include_guard = "MEDICALLIB_RUST_MEDICALLIB_H"
pragma_once = true
cpp_compat = true
usize_is_size_t = true
line_length = 100
tab_width = 4
autogen_warning = "/* This file is generated by cbindgen. Do not edit manually. */"
documentation = true
[parse]
expand = ["ffi"]
parse_deps = false
clean = true
+120 -29
View File
@@ -1,55 +1,146 @@
// Minimal C header for medicallib_rust FFI
// Build with: cargo build --features ffi --release
// Link your C app against the produced cdylib and ensure the header codes
// match the Rust definitions.
#ifndef MEDICALLIB_RUST_MEDICALLIB_H
#define MEDICALLIB_RUST_MEDICALLIB_H
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
/* This file is generated by cbindgen. Do not edit manually. */
#include <stdarg.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
// Return codes
/**
* Successful return code.
*/
#define ML_OK 0
/**
* Generic error.
*/
#define ML_ERR 1
/**
* Invalid argument.
*/
#define ML_EINVAL 2
typedef struct MLPatient MLPatient; // opaque
// Organ codes (keep in sync with Rust)
/**
* Organ code for `OrganType::Heart`.
*/
#define ML_ORGAN_HEART 0
/**
* Organ code for `OrganType::Lungs`.
*/
#define ML_ORGAN_LUNGS 1
/**
* Organ code for `OrganType::Brain`.
*/
#define ML_ORGAN_BRAIN 2
/**
* Organ code for `OrganType::SpinalCord`.
*/
#define ML_ORGAN_SPINAL_CORD 3
/**
* Organ code for `OrganType::Stomach`.
*/
#define ML_ORGAN_STOMACH 4
/**
* Organ code for `OrganType::Liver`.
*/
#define ML_ORGAN_LIVER 5
/**
* Organ code for `OrganType::Gallbladder`.
*/
#define ML_ORGAN_GALLBLADDER 6
/**
* Organ code for `OrganType::Pancreas`.
*/
#define ML_ORGAN_PANCREAS 7
/**
* Organ code for `OrganType::Intestines`.
*/
#define ML_ORGAN_INTESTINES 8
/**
* Organ code for `OrganType::Esophagus`.
*/
#define ML_ORGAN_ESOPHAGUS 9
/**
* Organ code for `OrganType::Kidneys`.
*/
#define ML_ORGAN_KIDNEYS 10
/**
* Organ code for `OrganType::Bladder`.
*/
#define ML_ORGAN_BLADDER 11
/**
* Organ code for `OrganType::Spleen`.
*/
#define ML_ORGAN_SPLEEN 12
// BMI computation. Returns 0 on success, writes to out_bmi.
int32_t medicallib_bmi(float weight_kg, float height_m, float* out_bmi);
// Patient lifecycle
MLPatient* ml_patient_new(const char* id);
void ml_patient_free(MLPatient* p);
// Returns a newly-allocated C string. Free with ml_string_free.
char* ml_patient_summary(const MLPatient* p);
void ml_string_free(char* s);
// Advance simulation by dt seconds.
int32_t ml_patient_update(MLPatient* p, float dt_seconds);
// Get summary for a specific organ type code.
char* ml_patient_organ_summary(const MLPatient* p, uint32_t organ_code);
/**
* Opaque patient handle type for C consumers. Wraps a heap-allocated `Patient`.
*/
typedef struct MLPatient {
void *inner;
} MLPatient;
#ifdef __cplusplus
}
#endif
extern "C" {
#endif // __cplusplus
/**
* Compute BMI; returns 0 on success, non-zero on error.
* On success writes result to `out_bmi`.
*/
int32_t medicallib_bmi(float weight_kg, float height_m, float *out_bmi);
/**
* Create a new patient with id string. Returns null on error.
*/
struct MLPatient *ml_patient_new(const char *id);
/**
* Destroy a patient handle. Accepts null.
*/
void ml_patient_free(struct MLPatient *p);
/**
* Get a newly-allocated C string summary for the patient.
* Returns null on error. Free the returned string with `ml_string_free`.
*/
char *ml_patient_summary(const struct MLPatient *p);
/**
* Frees a C string previously returned by this library. Accepts null.
*/
void ml_string_free(char *s);
/**
* Advance patient simulation by dt seconds.
*/
int32_t ml_patient_update(struct MLPatient *p, float dt_seconds);
/**
* Return organ summary by type code. See header for codes. Caller frees string.
*/
char *ml_patient_organ_summary(const struct MLPatient *p, uint32_t organ_code);
#ifdef __cplusplus
} // extern "C"
#endif // __cplusplus
#endif /* MEDICALLIB_RUST_MEDICALLIB_H */
+74 -28
View File
@@ -7,7 +7,7 @@
#![allow(unsafe_code)]
#![allow(clippy::not_unsafe_ptr_arg_deref)]
use core::ffi::{c_char, CStr};
use core::ffi::{c_char, c_void, CStr};
use std::ffi::CString;
use std::ptr;
@@ -21,11 +21,38 @@ pub const ML_ERR: i32 = 1;
/// Invalid argument.
pub const ML_EINVAL: i32 = 2;
/// Opaque patient handle type for C consumers.
/// Organ code for `OrganType::Heart`.
pub const ML_ORGAN_HEART: u32 = 0;
/// Organ code for `OrganType::Lungs`.
pub const ML_ORGAN_LUNGS: u32 = 1;
/// Organ code for `OrganType::Brain`.
pub const ML_ORGAN_BRAIN: u32 = 2;
/// Organ code for `OrganType::SpinalCord`.
pub const ML_ORGAN_SPINAL_CORD: u32 = 3;
/// Organ code for `OrganType::Stomach`.
pub const ML_ORGAN_STOMACH: u32 = 4;
/// Organ code for `OrganType::Liver`.
pub const ML_ORGAN_LIVER: u32 = 5;
/// Organ code for `OrganType::Gallbladder`.
pub const ML_ORGAN_GALLBLADDER: u32 = 6;
/// Organ code for `OrganType::Pancreas`.
pub const ML_ORGAN_PANCREAS: u32 = 7;
/// Organ code for `OrganType::Intestines`.
pub const ML_ORGAN_INTESTINES: u32 = 8;
/// Organ code for `OrganType::Esophagus`.
pub const ML_ORGAN_ESOPHAGUS: u32 = 9;
/// Organ code for `OrganType::Kidneys`.
pub const ML_ORGAN_KIDNEYS: u32 = 10;
/// Organ code for `OrganType::Bladder`.
pub const ML_ORGAN_BLADDER: u32 = 11;
/// Organ code for `OrganType::Spleen`.
pub const ML_ORGAN_SPLEEN: u32 = 12;
/// Opaque patient handle type for C consumers. Wraps a heap-allocated `Patient`.
#[repr(C)]
#[derive(Debug)]
pub struct MLPatient {
inner: Patient,
inner: *mut c_void,
}
/// Compute BMI; returns 0 on success, non-zero on error.
@@ -51,15 +78,17 @@ pub extern "C" fn ml_patient_new(id: *const c_char) -> *mut MLPatient {
return ptr::null_mut();
}
let cstr = unsafe { CStr::from_ptr(id) };
match cstr.to_str() {
let patient = match cstr.to_str() {
Ok(s) => match Patient::new(s) {
Ok(p) => Box::into_raw(Box::new(MLPatient {
inner: p.initialize_default(),
})),
Err(_) => ptr::null_mut(),
Ok(p) => p.initialize_default(),
Err(_) => return ptr::null_mut(),
},
Err(_) => ptr::null_mut(),
}
Err(_) => return ptr::null_mut(),
};
let patient_ptr: *mut Patient = Box::into_raw(Box::new(patient));
Box::into_raw(Box::new(MLPatient {
inner: patient_ptr.cast::<c_void>(),
}))
}
/// Destroy a patient handle. Accepts null.
@@ -69,7 +98,12 @@ pub extern "C" fn ml_patient_free(p: *mut MLPatient) {
return;
}
unsafe {
drop(Box::from_raw(p));
let mut handle = Box::from_raw(p);
if !handle.inner.is_null() {
let patient_ptr = handle.inner as *mut Patient;
drop(Box::from_raw(patient_ptr));
handle.inner = ptr::null_mut();
}
}
}
@@ -80,7 +114,11 @@ pub extern "C" fn ml_patient_summary(p: *const MLPatient) -> *mut c_char {
if p.is_null() {
return ptr::null_mut();
}
let summary = unsafe { (*p).inner.patient_summary() };
let patient_ptr = unsafe { (*p).inner as *const Patient };
if patient_ptr.is_null() {
return ptr::null_mut();
}
let summary = unsafe { (*patient_ptr).patient_summary() };
match CString::new(summary) {
Ok(s) => s.into_raw(),
Err(_) => ptr::null_mut(),
@@ -104,8 +142,12 @@ pub extern "C" fn ml_patient_update(p: *mut MLPatient, dt_seconds: f32) -> i32 {
if p.is_null() {
return ML_EINVAL;
}
let patient_ptr = unsafe { (*p).inner as *mut Patient };
if patient_ptr.is_null() {
return ML_EINVAL;
}
unsafe {
(*p).inner.update(dt_seconds);
(*patient_ptr).update(dt_seconds);
}
ML_OK
}
@@ -116,25 +158,29 @@ pub extern "C" fn ml_patient_organ_summary(p: *const MLPatient, organ_code: u32)
if p.is_null() {
return ptr::null_mut();
}
let patient_ptr = unsafe { (*p).inner as *const Patient };
if patient_ptr.is_null() {
return ptr::null_mut();
}
let kind = match organ_code {
0 => OrganType::Heart,
1 => OrganType::Lungs,
2 => OrganType::Brain,
3 => OrganType::SpinalCord,
4 => OrganType::Stomach,
5 => OrganType::Liver,
6 => OrganType::Gallbladder,
7 => OrganType::Pancreas,
8 => OrganType::Intestines,
9 => OrganType::Esophagus,
10 => OrganType::Kidneys,
11 => OrganType::Bladder,
12 => OrganType::Spleen,
ML_ORGAN_HEART => OrganType::Heart,
ML_ORGAN_LUNGS => OrganType::Lungs,
ML_ORGAN_BRAIN => OrganType::Brain,
ML_ORGAN_SPINAL_CORD => OrganType::SpinalCord,
ML_ORGAN_STOMACH => OrganType::Stomach,
ML_ORGAN_LIVER => OrganType::Liver,
ML_ORGAN_GALLBLADDER => OrganType::Gallbladder,
ML_ORGAN_PANCREAS => OrganType::Pancreas,
ML_ORGAN_INTESTINES => OrganType::Intestines,
ML_ORGAN_ESOPHAGUS => OrganType::Esophagus,
ML_ORGAN_KIDNEYS => OrganType::Kidneys,
ML_ORGAN_BLADDER => OrganType::Bladder,
ML_ORGAN_SPLEEN => OrganType::Spleen,
_ => return ptr::null_mut(),
};
let summary = unsafe { (*p).inner.organ_summary(kind) };
let summary = unsafe { (*patient_ptr).organ_summary(kind) };
match summary {
Ok(s) => match CString::new(s) {
Ok(text) => match CString::new(text) {
Ok(cs) => cs.into_raw(),
Err(_) => ptr::null_mut(),
},