diff --git a/.gitea/workflows/ci-reusable.yml b/.gitea/workflows/ci-reusable.yml index 94d3c74..457ddc8 100644 --- a/.gitea/workflows/ci-reusable.yml +++ b/.gitea/workflows/ci-reusable.yml @@ -39,7 +39,7 @@ jobs: - name: Test if: ${{ !inputs.package-only }} run: cargo test --all-features - + - name: Clippy if: ${{ !inputs.package-only }} run: cargo clippy --all-targets --all-features -- -D warnings diff --git a/AGENTS.md b/AGENTS.md index fefde88..72bf7f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` 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. diff --git a/Cargo.toml b/Cargo.toml index aecd405..540f613 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,3 +35,7 @@ opt-level = 3 [profile.dev] opt-level = 1 + +[build-dependencies] +cbindgen = '0.26' + diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..ad2e597 --- /dev/null +++ b/build.rs @@ -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"); + } +} diff --git a/cbindgen.toml b/cbindgen.toml new file mode 100644 index 0000000..7503bb7 --- /dev/null +++ b/cbindgen.toml @@ -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 diff --git a/ffi/medicallib.h b/ffi/medicallib.h index bb6ae00..5007732 100644 --- a/ffi/medicallib.h +++ b/ffi/medicallib.h @@ -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 +#include +#include #include +#include -// 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 */ diff --git a/src/ffi.rs b/src/ffi.rs index 84b0e22..b178736 100644 --- a/src/ffi.rs +++ b/src/ffi.rs @@ -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::(), + })) } /// 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(), },