diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..01bb045 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,77 @@ +name: Tests + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +jobs: + stub-tests: + name: Stub Tests (no dataset) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cargo test (stub mode) + run: cargo test -- --nocapture + + - name: Build library (debug) + run: cargo build + + - name: Fetch Catch2 + run: tests_cpp/fetch_catch2.sh + + - name: C++ ABI tests + run: | + cd tests_cpp + make test LIBDIR=../target/debug + + integration-tests: + name: Integration Tests (real data) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache test datasets + id: cache-testdata + uses: actions/cache@v4 + with: + path: testdata + key: test-data-v1 + + - name: Download test datasets + if: steps.cache-testdata.outputs.cache-hit != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p testdata + gh release download test-data-v1 -D testdata + unzip testdata/DDA_HeLa_50ng_5_6min.d.zip -d testdata + unzip testdata/DIA_HeLa_50ng_5_6min.d.zip -d testdata + rm testdata/*.d.zip + + - name: Build library (release, with timsrust) + run: cargo build --features with_timsrust --release + + - name: Rust integration tests + env: + TIMSRUST_TEST_DATA_DDA: testdata/20210510_TIMS03_EVO03_PaSk_MA_HeLa_50ng_5_6min_DDA_S1-B1_1_25185.d + TIMSRUST_TEST_DATA_DIA: testdata/20210510_TIMS03_EVO03_PaSk_SA_HeLa_50ng_5_6min_DIA_high_speed_S1-B2_1_25186.d + run: cargo test --features with_timsrust -- --nocapture + + - name: Fetch Catch2 + run: tests_cpp/fetch_catch2.sh + + - name: C++ smoke tests + run: | + cd tests_cpp + make test LIBDIR=../target/release \ + DDA=../testdata/20210510_TIMS03_EVO03_PaSk_MA_HeLa_50ng_5_6min_DDA_S1-B1_1_25185.d \ + DIA=../testdata/20210510_TIMS03_EVO03_PaSk_SA_HeLa_50ng_5_6min_DIA_high_speed_S1-B2_1_25186.d diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b0c1bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Build artifacts +target/ + +# Catch2 amalgamated files (download via tests_cpp/fetch_catch2.sh) +tests_cpp/catch2/ + +# Compiled test objects and binary +tests_cpp/*.o +tests_cpp/run_tests diff --git a/Cargo.toml b/Cargo.toml index 05a6f25..6502298 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ description = "C FFI wrapper around timsrust for TDF/miniTDF access" [lib] # We want something C/C++ can link against -crate-type = ["cdylib", "staticlib"] +crate-type = ["cdylib", "staticlib", "lib"] [dependencies] # timsrust is optional for environments where the native crate is available. @@ -26,3 +26,6 @@ with_timsrust = ["timsrust"] # only if you want auto header generation cbindgen = "0.27" +[dev-dependencies] +libc = "0.2" + diff --git a/README.md b/README.md index 593241d..62e1687 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,55 @@ int main(int argc, char** argv) { } ``` +## Testing + +The project uses a two-tier testing strategy: **stub tests** (no data needed) and **integration tests** (real Bruker datasets). + +### Stub Tests + +Run the FFI contract tests against the lightweight stub build (no `timsrust` dependency, no datasets): + +```bash +cargo test +``` + +C++ ABI layout tests (requires Catch2, fetched automatically): + +```bash +cargo build +tests_cpp/fetch_catch2.sh +cd tests_cpp && make test LIBDIR=../target/debug +``` + +### Integration Tests (Real Data) + +Integration tests require real Bruker timsTOF Pro `.d` datasets. Test data is available as [GitHub release artifacts](https://github.com/OpenMS/timsrust_cpp_bridge/releases/tag/test-data-v1) (HeLa 50ng, 5.6-min gradient, from [PRIDE PXD027359](https://www.ebi.ac.uk/pride/archive/projects/PXD027359)). + +Download and extract the datasets, then point the env vars at them: + +```bash +# Download and extract +gh release download test-data-v1 -D testdata +unzip testdata/DDA_HeLa_50ng_5_6min.d.zip -d testdata +unzip testdata/DIA_HeLa_50ng_5_6min.d.zip -d testdata + +# Run Rust integration tests +TIMSRUST_TEST_DATA_DDA=testdata/20210510_TIMS03_EVO03_PaSk_MA_HeLa_50ng_5_6min_DDA_S1-B1_1_25185.d \ +TIMSRUST_TEST_DATA_DIA=testdata/20210510_TIMS03_EVO03_PaSk_SA_HeLa_50ng_5_6min_DIA_high_speed_S1-B2_1_25186.d \ +cargo test --features with_timsrust -- --nocapture + +# Run C++ smoke tests (after building with --features with_timsrust --release) +cd tests_cpp && make test LIBDIR=../target/release \ + DDA=../testdata/20210510_TIMS03_EVO03_PaSk_MA_HeLa_50ng_5_6min_DDA_S1-B1_1_25185.d \ + DIA=../testdata/20210510_TIMS03_EVO03_PaSk_SA_HeLa_50ng_5_6min_DIA_high_speed_S1-B2_1_25186.d +``` + +Tests gracefully skip if the env vars are unset. + +### CI + +Both test tiers run on every push and PR via GitHub Actions. Integration test datasets are cached to avoid repeated downloads. + ## Notes and Caveats - For DIA-PASEF datasets, `tims_num_spectra` reflects expanded MS2 spectra, while `tims_num_frames` reflects raw LC frames (including MS1). diff --git a/docs/openms-integration-plan.md b/docs/openms-integration-plan.md new file mode 100644 index 0000000..2b05710 --- /dev/null +++ b/docs/openms-integration-plan.md @@ -0,0 +1,513 @@ +# OpenMS Integration Plan — timsrust_cpp_bridge + +## Goal + +Add native Bruker `.d` (TDF/miniTDF) reading support to OpenMS by integrating the `timsrust_cpp_bridge` static library. This replaces the need for Bruker's proprietary SDK and provides cross-platform support (Linux x86_64, Linux ARM, macOS ARM, Windows x86_64). + +## Architecture + +OpenMS links the pre-built `timsrust_cpp_bridge` static library (C ABI) via CMake `find_package`. A new `BrukerTdfFile` format handler wraps the C API and converts `tims_spectrum`/`tims_frame` structs into OpenMS `MSSpectrum`/`MSExperiment` objects. The dependency is optional — gated behind a `WITH_TIMSRUST` CMake option. + +``` +timsrust_cpp_bridge (static .a/.lib) + | + v (C ABI) +BrukerTdfFile.cpp ← new OpenMS format handler + | + v +MSExperiment / MSSpectrum / Precursor (standard OpenMS types) +``` + +## Prerequisites + +- A tagged release of `timsrust_cpp_bridge` (e.g., `v0.1.0`) producing platform archives: + - `timsrust_cpp_bridge-v0.1.0-linux-x86_64.tar.gz` + - `timsrust_cpp_bridge-v0.1.0-linux-aarch64.tar.gz` + - `timsrust_cpp_bridge-v0.1.0-macos-arm64.tar.gz` + - `timsrust_cpp_bridge-v0.1.0-windows-x86_64.zip` +- Each archive contains: `include/timsrust_cpp_bridge.h`, `lib/libtimsrust_cpp_bridge.a` (or `.lib`), and CMake config files supporting `find_package(timsrust_cpp_bridge REQUIRED)`. + +--- + +## Task 1: Add timsrust_cpp_bridge to OpenMS contrib / ThirdParty + +### Option A: ThirdParty submodule (recommended) + +Download and extract the platform archive into `OpenMS/THIRDPARTY//timsrust_cpp_bridge/` so it sits alongside other vendored dependencies. + +### Option B: System-installed package + +Users run `cmake -DCMAKE_PREFIX_PATH=/path/to/timsrust_cpp_bridge ...` to point at the extracted archive. + +Either way, the CMake config file shipped with the archive handles include paths, library location, and platform-specific link dependencies (pthread/dl/m on Linux, frameworks on macOS, system libs on Windows). + +--- + +## Task 2: CMake integration + +### 2.1 Add option gate + +In `CMakeLists.txt` (top-level or `cmake_findExternalLibs.cmake`): + +```cmake +option(WITH_TIMSRUST "Enable Bruker TDF/miniTDF support via timsrust_cpp_bridge" OFF) +``` + +### 2.2 Find the package + +```cmake +if(WITH_TIMSRUST) + find_package(timsrust_cpp_bridge REQUIRED) + set(TIMSRUST_FOUND TRUE) + add_definitions(-DWITH_TIMSRUST) +endif() +``` + +### 2.3 Link to OpenMS + +In `src/openms/CMakeLists.txt`, add to the private dependencies: + +```cmake +if(TIMSRUST_FOUND) + list(APPEND OPENMS_DEP_PRIVATE_LIBRARIES timsrust_cpp_bridge::timsrust_cpp_bridge) +endif() +``` + +The `timsrust_cpp_bridge::timsrust_cpp_bridge` imported target automatically brings in the correct include directory and platform link libraries. + +--- + +## Task 3: Register the file type + +### 3.1 `FileTypes.h` + +Add to the `Type` enum: + +```cpp +BRUKER_TDF, ///< Bruker TDF (.d directory, timsTOF) +``` + +### 3.2 `FileTypes.cpp` + +Add to `type_with_annotation__`: + +```cpp +{Type::BRUKER_TDF, "d", "Bruker TDF dataset (timsTOF)", + {PROP::PROVIDES_EXPERIMENT, PROP::PROVIDES_SPECTRUM, PROP::READABLE}}, +``` + +Note: The `.d` extension is a directory, not a file. `getTypeByFileName()` may need adjustment to check for directory existence and the presence of `analysis.tdf` inside it. + +### 3.3 `FileHandler.cpp` + +Add case to `loadExperiment()`: + +```cpp +#ifdef WITH_TIMSRUST +case FileTypes::BRUKER_TDF: +{ + BrukerTdfFile f; + f.setLogType(log); + f.load(filename, exp); + break; +} +#endif +``` + +--- + +## Task 4: Implement BrukerTdfFile + +### 4.1 Header + +`src/openms/include/OpenMS/FORMAT/BrukerTdfFile.h` + +```cpp +#pragma once + +#ifdef WITH_TIMSRUST + +#include +#include + +namespace OpenMS +{ + class OPENMS_DLLAPI BrukerTdfFile : public ProgressLogger + { + public: + BrukerTdfFile() = default; + + /// Load full experiment (all spectra) from a .d directory + void load(const String& path, MSExperiment& exp); + + /// Load only spectra near a given RT (for targeted access) + void loadByRT(const String& path, double rt_seconds, int n_spectra, + double im_lower, double im_upper, MSExperiment& exp); + }; +} + +#endif // WITH_TIMSRUST +``` + +### 4.2 Implementation + +`src/openms/source/FORMAT/BrukerTdfFile.cpp` + +```cpp +#ifdef WITH_TIMSRUST + +#include +#include +#include + +namespace OpenMS +{ + + /// RAII wrapper for tims_dataset handle + struct TimsHandle + { + tims_dataset* ds = nullptr; + + ~TimsHandle() + { + if (ds) tims_close(ds); + } + + void open(const String& path) + { + timsffi_status st = tims_open(path.c_str(), &ds); + if (st != TIMSFFI_OK || ds == nullptr) + { + char err[1024]{}; + tims_get_last_error(nullptr, err, sizeof(err)); + throw Exception::FileNotReadable( + __FILE__, __LINE__, OPENMS_PRETTY_FUNCTION, path, String(err)); + } + } + }; + + /// Convert a tims_spectrum to an OpenMS MSSpectrum + static MSSpectrum convertSpectrum(const tims_spectrum& ts) + { + MSSpectrum spec; + spec.setRT(ts.rt_seconds); + spec.setMSLevel(ts.ms_level); + + // Copy peak data + spec.resize(ts.num_peaks); + for (uint32_t j = 0; j < ts.num_peaks; ++j) + { + spec[j] = Peak1D(static_cast(ts.mz[j]), + static_cast(ts.intensity[j])); + } + + // Precursor info for MS2 + if (ts.ms_level == 2) + { + Precursor prec; + prec.setMZ(ts.precursor_mz); + prec.setIntensity(ts.precursor_intensity); + prec.setCharge(static_cast(ts.charge)); + prec.setIsolationWindowLowerOffset(ts.isolation_width / 2.0); + prec.setIsolationWindowUpperOffset(ts.isolation_width / 2.0); + prec.setDriftTime(ts.im); + spec.getPrecursors().push_back(prec); + } + + // Ion mobility as drift time on the spectrum itself + spec.setDriftTime(ts.im); + + // Native ID for traceability + spec.setNativeID("spectrum=" + String(ts.index)); + + return spec; + } + + void BrukerTdfFile::load(const String& path, MSExperiment& exp) + { + TimsHandle h; + h.open(path); + + unsigned int n = tims_num_spectra(h.ds); + exp.clear(true); + exp.reserve(n); + + startProgress(0, n, "Loading Bruker TDF"); + for (unsigned int i = 0; i < n; ++i) + { + setProgress(i); + tims_spectrum ts{}; + if (tims_get_spectrum(h.ds, i, &ts) != TIMSFFI_OK) + { + continue; // skip unreadable spectra + } + exp.addSpectrum(convertSpectrum(ts)); + } + endProgress(); + + // Sort by RT for downstream compatibility + exp.sortSpectra(true); + } + + void BrukerTdfFile::loadByRT(const String& path, double rt_seconds, + int n_spectra, double im_lower, + double im_upper, MSExperiment& exp) + { + TimsHandle h; + h.open(path); + + unsigned int count = 0; + tims_spectrum* specs = nullptr; + timsffi_status st = tims_get_spectra_by_rt( + h.ds, rt_seconds, n_spectra, im_lower, im_upper, &count, &specs); + + if (st != TIMSFFI_OK) + { + char err[1024]{}; + tims_get_last_error(h.ds, err, sizeof(err)); + throw Exception::BaseException( + __FILE__, __LINE__, OPENMS_PRETTY_FUNCTION, + "BrukerTdfFile", String("Failed to query spectra by RT: ") + err); + } + + exp.clear(true); + exp.reserve(count); + + for (unsigned int i = 0; i < count; ++i) + { + exp.addSpectrum(convertSpectrum(specs[i])); + } + + tims_free_spectrum_array(h.ds, specs, count); + exp.sortSpectra(true); + } + +} // namespace OpenMS + +#endif // WITH_TIMSRUST +``` + +### 4.3 Register source file + +In `src/openms/source/FORMAT/sources.cmake`, add: + +```cmake +if(WITH_TIMSRUST) + list(APPEND FORMAT_sources BrukerTdfFile.cpp) +endif() +``` + +--- + +## Task 5: DIA-PASEF metadata + +For DIA workflows, OpenMS needs SWATH/isolation window information. Add a helper that populates `SwathMap`-compatible structures from `tims_get_swath_windows`: + +```cpp +/// Populate SWATH window metadata for DIA-PASEF runs +static std::vector getSwathMaps(tims_dataset* ds) +{ + unsigned int count = 0; + tims_swath_window* windows = nullptr; + tims_get_swath_windows(ds, &count, &windows); + + std::vector maps; + maps.reserve(count); + + for (unsigned int i = 0; i < count; ++i) + { + SwathMap m; + m.lower = windows[i].mz_lower; + m.upper = windows[i].mz_upper; + m.center = windows[i].mz_center; + m.ms1 = (windows[i].is_ms1 != 0); + m.imLower = windows[i].im_lower; + m.imUpper = windows[i].im_upper; + maps.push_back(m); + } + + tims_free_swath_windows(ds, windows); + return maps; +} +``` + +This can be exposed via `BrukerTdfFile::getSwathMaps(const String& path)` for OpenSWATH and other DIA analysis tools. + +--- + +## Task 6: Config support (centroiding, smoothing, calibration) + +Expose reader config to allow pipelines to control processing at read time: + +```cpp +void BrukerTdfFile::load(const String& path, MSExperiment& exp, + uint32_t smoothing_window, + uint32_t centroiding_window, + bool calibrate) +{ + tims_config* cfg = tims_config_create(); + tims_config_set_smoothing_window(cfg, smoothing_window); + tims_config_set_centroiding_window(cfg, centroiding_window); + tims_config_set_calibrate(cfg, calibrate ? 1 : 0); + + TimsHandle h; + // Use open_with_config instead of open + timsffi_status st = tims_open_with_config(path.c_str(), cfg, &h.ds); + tims_config_free(cfg); + + if (st != TIMSFFI_OK) + { + char err[1024]{}; + tims_get_last_error(nullptr, err, sizeof(err)); + throw Exception::FileNotReadable( + __FILE__, __LINE__, OPENMS_PRETTY_FUNCTION, path, String(err)); + } + + // ... same loading loop as basic load() ... +} +``` + +Note: timsrust 0.4.2 may panic with `calibrate=on` on some datasets. The bridge catches these panics and returns `TIMSFFI_ERR_OPEN_FAILED` — handle gracefully. + +--- + +## Task 7: Converter utilities + +For frame-level workflows that work with raw TOF indices and scan numbers, expose the converters: + +```cpp +/// Convert raw TOF indices from a frame to m/z values +static void convertTofToMz(tims_dataset* ds, const uint32_t* tof_indices, + uint32_t count, std::vector& mz_out) +{ + mz_out.resize(count); + tims_convert_tof_to_mz_array(ds, tof_indices, count, mz_out.data()); +} + +/// Convert scan indices to ion mobility (1/K0) values +static void convertScanToIm(tims_dataset* ds, const uint32_t* scan_indices, + uint32_t count, std::vector& im_out) +{ + im_out.resize(count); + tims_convert_scan_to_im_array(ds, scan_indices, count, im_out.data()); +} +``` + +These are needed if OpenMS processes frames directly (e.g., for feature detection in the 4D RT-IM-m/z-intensity space). + +--- + +## Task 8: Tests + +### 8.1 Unit test + +`src/tests/class_tests/openms/source/BrukerTdfFile_test.cpp` + +```cpp +#ifdef WITH_TIMSRUST + +#include +#include + +START_TEST(BrukerTdfFile, "$Id$") + +// These tests require TIMSRUST_TEST_DATA_DDA env var pointing to a .d dataset +START_SECTION(load) +{ + const char* dda_path = std::getenv("TIMSRUST_TEST_DATA_DDA"); + if (!dda_path) + { + STATUS("TIMSRUST_TEST_DATA_DDA not set, skipping"); + } + else + { + MSExperiment exp; + BrukerTdfFile f; + f.load(String(dda_path), exp); + TEST_NOT_EQUAL(exp.size(), 0) + TEST_EQUAL(exp[0].getMSLevel() == 1 || exp[0].getMSLevel() == 2, true) + TEST_NOT_EQUAL(exp[0].size(), 0) // has peaks + } +} +END_SECTION + +END_TEST + +#endif +``` + +### 8.2 Test data + +Use the same datasets already stored as GitHub release artifacts: +- `DDA_HeLa_50ng_5_6min.d.zip` (345 MB) — `test-data-v1` release +- `DIA_HeLa_50ng_5_6min.d.zip` (329 MB) — same release + +Set `TIMSRUST_TEST_DATA_DDA` / `TIMSRUST_TEST_DATA_DIA` in CI to run the data-dependent tests. + +--- + +## Task 9: CI + +### OpenMS CI changes + +Add a job or matrix entry that: + +1. Downloads the `timsrust_cpp_bridge` archive for the CI platform +2. Passes `-DWITH_TIMSRUST=ON -DCMAKE_PREFIX_PATH=` to CMake +3. Runs the `BrukerTdfFile_test` with dataset env vars + +The datasets can be cached using `actions/cache` (same strategy as this repo's CI). + +--- + +## Files to create/modify in OpenMS + +| Action | File | Description | +|--------|------|-------------| +| Create | `src/openms/include/OpenMS/FORMAT/BrukerTdfFile.h` | Format handler header | +| Create | `src/openms/source/FORMAT/BrukerTdfFile.cpp` | Format handler implementation | +| Create | `src/tests/class_tests/openms/source/BrukerTdfFile_test.cpp` | Unit test | +| Modify | `src/openms/include/OpenMS/FORMAT/FileTypes.h` | Add `BRUKER_TDF` enum | +| Modify | `src/openms/source/FORMAT/FileTypes.cpp` | Register type annotation | +| Modify | `src/openms/source/FORMAT/FileHandler.cpp` | Add load case | +| Modify | `src/openms/source/FORMAT/sources.cmake` | Register new source file | +| Modify | `CMakeLists.txt` or `cmake_findExternalLibs.cmake` | `WITH_TIMSRUST` option + `find_package` | + +--- + +## API Quick Reference + +| C function | Purpose | OpenMS usage | +|---|---|---| +| `tims_open` / `tims_close` | Lifecycle | RAII `TimsHandle` wrapper | +| `tims_open_with_config` | Open with processing config | Centroiding/smoothing at read time | +| `tims_num_spectra` | Expanded MS2 count (DIA-PASEF) | Reserve experiment size | +| `tims_num_frames` | Raw LC frame count | FileInfo, mzML-equivalent count | +| `tims_get_spectrum` | Single spectrum by index | Main loading loop | +| `tims_get_spectra_by_rt` | Batch query near RT + IM filter | Targeted access, `loadByRT()` | +| `tims_free_spectrum_array` | Free batch results | After `tims_get_spectra_by_rt` | +| `tims_get_frame` | Single raw frame | Frame-level 4D processing | +| `tims_get_frames_by_level` | All MS1 or MS2 frames | Bulk frame access | +| `tims_free_frame_array` | Free batch frame results | After `tims_get_frames_by_level` | +| `tims_get_swath_windows` | DIA isolation windows | OpenSWATH integration | +| `tims_free_swath_windows` | Free window array | After `tims_get_swath_windows` | +| `tims_file_info` | Aggregate statistics | FileInfo output | +| `tims_get_last_error` | Error message retrieval | Exception messages | +| `tims_convert_tof_to_mz` | TOF → m/z (scalar) | Frame-level conversion | +| `tims_convert_scan_to_im` | Scan → 1/K0 (scalar) | Frame-level conversion | +| `tims_convert_tof_to_mz_array` | TOF → m/z (batch) | Bulk frame conversion | +| `tims_convert_scan_to_im_array` | Scan → 1/K0 (batch) | Bulk frame conversion | +| `tims_config_create` / `_free` | Config lifecycle | Processing params | +| `tims_config_set_*` | Config setters | Centroiding, smoothing, calibration | + +--- + +## Memory Ownership Summary + +| API | Ownership | Lifetime | Free with | +|---|---|---|---| +| `tims_get_spectrum` | Handle-owned buffers | Until next call on same handle | Don't free — just copy | +| `tims_get_spectra_by_rt` | Caller-owned (malloc'd) | Until freed | `tims_free_spectrum_array` | +| `tims_get_frame` | Handle-owned buffers | Until next `tims_get_frame` on same handle | Don't free — just copy | +| `tims_get_frames_by_level` | Caller-owned (malloc'd) | Until freed | `tims_free_frame_array` | +| `tims_get_swath_windows` | Caller-owned (malloc'd) | Until freed | `tims_free_swath_windows` | diff --git a/docs/superpowers/plans/2026-03-11-sage-api-parity.md b/docs/superpowers/plans/2026-03-11-sage-api-parity.md new file mode 100644 index 0000000..27b21c1 --- /dev/null +++ b/docs/superpowers/plans/2026-03-11-sage-api-parity.md @@ -0,0 +1,1398 @@ +# Sage API Parity Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expose all timsrust functionality that Sage uses through the C FFI bridge — frame-level access, converters, configurable reader construction, and extended spectrum metadata. + +**Architecture:** Four independent feature areas added incrementally: (1) extend existing `TimsFfiSpectrum` with missing fields, (2) add raw frame-level access via `FrameReader`, (3) expose TOF→m/z and scan→IM converters, (4) add opaque config builder for `SpectrumReaderConfig`. Each area follows the existing pattern of `types.rs` structs → `dataset.rs` logic → `lib.rs` FFI exports → C header updates. Both `with_timsrust` and stub builds are maintained. + +**Tech Stack:** Rust (FFI via `extern "C"`, `#[repr(C)]`), timsrust 0.4.2, libc for malloc/free, C17 header + +**Spec:** `docs/superpowers/specs/2026-03-11-sage-api-parity-design.md` + +--- + +## Chunk 1: Extended TimsFfiSpectrum + +### Task 1: Add new fields to TimsFfiSpectrum + +**Files:** +- Modify: `src/types.rs:4-14` + +- [ ] **Step 1: Add new fields to the struct** + +In `src/types.rs`, extend `TimsFfiSpectrum` by appending after `im`: + +```rust +#[repr(C)] +#[derive(Clone, Copy)] +pub struct TimsFfiSpectrum { + pub rt_seconds: c_double, + pub precursor_mz: c_double, + pub ms_level: c_uchar, + pub num_peaks: c_uint, + pub mz: *const c_float, + pub intensity: *const c_float, + pub im: c_double, + // New fields for Sage parity + pub index: c_uint, // Spectrum.index from SpectrumReader + pub isolation_width: c_double, // isolation window width (0.0 if N/A) + pub isolation_mz: c_double, // isolation window center m/z (0.0 if N/A) + pub charge: c_uchar, // precursor charge (0 = unknown) + pub precursor_intensity: c_double, // precursor intensity (f64::NAN = unknown) + pub frame_index: c_uint, // precursor frame index (u32::MAX = N/A, i.e. MS1) +} +``` + +- [ ] **Step 2: Verify stub build compiles** + +Run: `cargo check` +Expected: Compilation errors in `dataset.rs` and `lib.rs` where `TimsFfiSpectrum` is constructed without the new fields. This confirms the type change propagated. + +### Task 2: Populate new fields in get_spectrum (dataset.rs) + +**Files:** +- Modify: `src/dataset.rs:161-223` (the `get_spectrum` method) + +- [ ] **Step 1: Update the with_timsrust branch of get_spectrum** + +In `src/dataset.rs`, replace the `#[cfg(feature = "with_timsrust")]` block inside `get_spectrum` to populate new fields: + +```rust + #[cfg(feature = "with_timsrust")] + { + let spec = self.reader.get(index as usize) + .map_err(|_| TimsFfiStatus::IndexOutOfBounds)?; + + let n = spec.len(); + self.mz_buf.clear(); + self.mz_buf.reserve(n); + for &v in spec.mz_values.iter() { + self.mz_buf.push(v as f32); + } + self.int_buf.clear(); + self.int_buf.reserve(n); + for &v in spec.intensities.iter() { + self.int_buf.push(v as f32); + } + + out.num_peaks = n as u32; + out.mz = if n == 0 { ptr::null() } else { self.mz_buf.as_ptr() }; + out.intensity = if n == 0 { ptr::null() } else { self.int_buf.as_ptr() }; + out.index = spec.index as u32; + out.isolation_width = spec.isolation_width; + out.isolation_mz = spec.isolation_mz; + + if let Some(prec) = spec.precursor { + out.rt_seconds = prec.rt; + out.precursor_mz = prec.mz; + out.im = prec.im; + out.ms_level = 2; + out.charge = prec.charge.map(|c| c as u8).unwrap_or(0); + out.precursor_intensity = prec.intensity.unwrap_or(f64::NAN); + out.frame_index = prec.frame_index as u32; + } else { + out.rt_seconds = 0.0; + out.precursor_mz = 0.0; + out.im = 0.0; + out.ms_level = 1; + out.charge = 0; + out.precursor_intensity = f64::NAN; + out.frame_index = u32::MAX; + } + + return Ok(()); + } +``` + +- [ ] **Step 2: Update the stub branch of get_spectrum** + +In the `#[cfg(not(feature = "with_timsrust"))]` block, add the new fields with sentinel values: + +```rust + #[cfg(not(feature = "with_timsrust"))] + { + out.rt_seconds = 0.0; + out.precursor_mz = 0.0; + out.ms_level = 0; + out.num_peaks = 0; + out.mz = ptr::null(); + out.intensity = ptr::null(); + out.im = 0.0; + out.index = 0; + out.isolation_width = 0.0; + out.isolation_mz = 0.0; + out.charge = 0; + out.precursor_intensity = f64::NAN; + out.frame_index = u32::MAX; + Ok(()) + } +``` + +- [ ] **Step 3: Verify stub build compiles** + +Run: `cargo check` +Expected: May still fail due to `TimsFfiSpectrum` construction in `lib.rs` (`tims_get_spectra_by_rt`). That's expected — fixed in the next task. + +### Task 3: Populate new fields in tims_get_spectra_by_rt (lib.rs) + +**Files:** +- Modify: `src/lib.rs:315-323` (the `TimsFfiSpectrum` literal in `tims_get_spectra_by_rt`) + +- [ ] **Step 1: Update the TimsFfiSpectrum construction in tims_get_spectra_by_rt** + +Replace the `let out_spec = TimsFfiSpectrum { ... }` block (around line 315) with: + +```rust + let out_spec = TimsFfiSpectrum { + rt_seconds: spec.precursor.map(|p| p.rt).unwrap_or(0.0), + precursor_mz: spec.precursor.map(|p| p.mz).unwrap_or(0.0), + ms_level: if spec.precursor.is_some() { 2 } else { 1 }, + num_peaks: n as u32, + mz: if mz_ptr.is_null() { std::ptr::null() } else { mz_ptr as *const f32 }, + intensity: if int_ptr.is_null() { std::ptr::null() } else { int_ptr as *const f32 }, + im: spec.precursor.map(|p| p.im).unwrap_or(0.0), + index: spec.index as u32, + isolation_width: spec.isolation_width, + isolation_mz: spec.isolation_mz, + charge: spec.precursor.and_then(|p| p.charge).map(|c| c as u8).unwrap_or(0), + precursor_intensity: spec.precursor.and_then(|p| p.intensity).unwrap_or(f64::NAN), + frame_index: spec.precursor.map(|p| p.frame_index as u32).unwrap_or(u32::MAX), + }; +``` + +- [ ] **Step 2: Verify stub build compiles cleanly** + +Run: `cargo check` +Expected: PASS — all `TimsFfiSpectrum` construction sites now include new fields. + +- [ ] **Step 3: Also verify with timsrust feature (if available)** + +Run: `cargo check --features with_timsrust` +Expected: PASS (confirms timsrust field names like `spec.isolation_width`, `spec.isolation_mz`, `prec.charge`, `prec.intensity`, `prec.frame_index` are correct). If this fails due to missing timsrust dependency in the environment, defer to CI. + +- [ ] **Step 4: Update C header tims_spectrum struct** + +In `include/timsrust_cpp_bridge.h`, replace the existing `tims_spectrum` typedef to match the Rust struct: + +```c +typedef struct { + double rt_seconds; + double precursor_mz; + uint8_t ms_level; + uint32_t num_peaks; + const float* mz; + const float* intensity; + double im; + /* Sage-parity fields */ + uint32_t index; /* spectrum index from SpectrumReader */ + double isolation_width; /* isolation window width (0.0 if N/A) */ + double isolation_mz; /* isolation window center m/z (0.0 if N/A) */ + uint8_t charge; /* precursor charge (0 = unknown) */ + double precursor_intensity; /* precursor intensity (NaN = unknown) */ + uint32_t frame_index; /* precursor frame index (UINT32_MAX for MS1) */ +} tims_spectrum; +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/types.rs src/dataset.rs src/lib.rs include/timsrust_cpp_bridge.h +git commit -m "feat: extend TimsFfiSpectrum with index, isolation, charge, precursor_intensity, frame_index" +``` + +--- + +## Chunk 2: Frame-Level Access + +### Task 4: Add TimsFfiFrame type + +**Files:** +- Modify: `src/types.rs` + +- [ ] **Step 1: Add TimsFfiFrame struct** + +Append to `src/types.rs` (before the closing of the file): + +```rust +#[repr(C)] +#[derive(Clone, Copy)] +pub struct TimsFfiFrame { + pub index: c_uint, + pub rt_seconds: c_double, + pub ms_level: c_uchar, // 1=MS1, 2=MS2, 0=Unknown + pub num_scans: c_uint, + pub num_peaks: c_uint, // total peaks (length of tof_indices & intensities) + pub tof_indices: *const u32, // raw TOF indices, flat array + pub intensities: *const u32, // raw intensities, flat array + pub scan_offsets: *const u64, // per-scan offsets (length: num_scans + 1) +} +``` + +- [ ] **Step 2: Verify stub build compiles** + +Run: `cargo check` +Expected: PASS — the new type is defined but not yet used. + +### Task 5: Add FrameReader and converters to TimsDataset + +**Files:** +- Modify: `src/dataset.rs` + +This is the largest task. It adds `FrameReader`, converters, frame buffers, and methods for frame access. + +- [ ] **Step 1: Add timsrust imports for FrameReader, MetadataReader, converters** + +At the top of `src/dataset.rs`, after the existing `#[cfg(feature = "with_timsrust")]` imports, add: + +```rust +#[cfg(feature = "with_timsrust")] +use timsrust::readers::FrameReader; +#[cfg(feature = "with_timsrust")] +use timsrust::converters::{Tof2MzConverter, Scan2ImConverter}; +``` + +Also add the new type import to the `use crate::types` line: + +```rust +use crate::types::{TimsFfiSpectrum, TimsFfiSwathWindow, TimsFfiFrame, TimsFfiStatus}; +``` + +- [ ] **Step 2: Add stub FrameReader and converter types** + +After the existing stub `SpectrumReader` block, add stubs for `FrameReader` and converters: + +```rust +#[cfg(not(feature = "with_timsrust"))] +struct FrameReader { + n: usize, +} + +#[cfg(not(feature = "with_timsrust"))] +impl FrameReader { + fn new(_path: &str) -> Result { + Ok(FrameReader { n: 0 }) + } + fn len(&self) -> usize { + self.n + } +} + +#[cfg(not(feature = "with_timsrust"))] +struct Tof2MzConverter; + +#[cfg(not(feature = "with_timsrust"))] +struct Scan2ImConverter; + +#[cfg(not(feature = "with_timsrust"))] +impl Tof2MzConverter { + fn convert(&self, value: f64) -> f64 { value } +} + +#[cfg(not(feature = "with_timsrust"))] +impl Scan2ImConverter { + fn convert(&self, value: f64) -> f64 { value } +} +``` + +- [ ] **Step 3: Extend TimsDataset struct with new fields** + +Replace the `TimsDataset` struct definition: + +```rust +pub struct TimsDataset { + pub(crate) reader: SpectrumReader, + // Reusable buffers for mz/intensity (spectrum access) + mz_buf: Vec, + int_buf: Vec, + // Reusable buffers for frame access (independent of spectrum buffers) + frame_tof_buf: Vec, + frame_int_buf: Vec, + frame_scan_offset_buf: Vec, + // FrameReader for raw frame-level access (pub(crate) for lib.rs batch access) + pub(crate) frame_reader: FrameReader, + // Converters: TOF index → m/z, scan index → ion mobility + pub(crate) mz_converter: Tof2MzConverter, + pub(crate) im_converter: Scan2ImConverter, + // Optional cached swath windows computed at open time when available + swath_windows: Option>, + // Optional last error string for this handle + pub(crate) last_error: Option, + // RT index: sorted (rt_seconds, spectrum_index) pairs for fast lookup + rt_index: Vec<(f64, usize)>, +} +``` + +Note: `num_frames` field removed — replaced by `frame_reader.len()`. + +- [ ] **Step 4: Update the `open` method — with_timsrust branch** + +In the `#[cfg(feature = "with_timsrust")]` section of `open()`, replace the `(swath_windows, num_frames)` block to also construct `frame_reader` and extract converters. The FrameReader and MetadataReader are already being created temporarily — now we keep them: + +```rust + #[cfg(feature = "with_timsrust")] + let (swath_windows, frame_reader, mz_converter, im_converter) = { + use timsrust::readers::QuadrupoleSettingsReader; + + let fr = FrameReader::new(path_str) + .map_err(|_| TimsFfiStatus::OpenFailed)?; + + let metadata = MetadataReader::new(path_str) + .map_err(|_| TimsFfiStatus::OpenFailed)?; + let mz_conv = metadata.mz_converter; + let im_conv = metadata.im_converter; + + let mut out: Vec = Vec::new(); + if let Ok(quads) = QuadrupoleSettingsReader::new(path_str) { + for quad in quads.iter() { + for i in 0..quad.len() { + let center = quad.isolation_mz[i]; + let width = quad.isolation_width[i]; + let mz_lower = center - width / 2.0; + let mz_upper = center + width / 2.0; + let (im_lower, im_upper) = if i < quad.scan_starts.len() && i < quad.scan_ends.len() { + let start = quad.scan_starts[i] as f64; + let end = quad.scan_ends[i] as f64; + let im_start = im_conv.convert(start); + let im_end = im_conv.convert(end); + (im_start.min(im_end), im_start.max(im_end)) + } else { + (metadata.lower_im, metadata.upper_im) + }; + out.push(TimsFfiSwathWindow { + mz_lower, + mz_upper, + mz_center: center, + im_lower, + im_upper, + is_ms1: 0, + }); + } + } + } + let sw = if out.is_empty() { None } else { Some(out) }; + (sw, fr, mz_conv, im_conv) + }; +``` + +- [ ] **Step 5: Update the `open` method — stub branch** + +Replace the stub branch: + +```rust + #[cfg(not(feature = "with_timsrust"))] + let (swath_windows, frame_reader, mz_converter, im_converter) = { + let fr = FrameReader::new("").map_err(|_| TimsFfiStatus::OpenFailed)?; + (None, fr, Tof2MzConverter, Scan2ImConverter) + }; +``` + +- [ ] **Step 6: Update the struct construction in `open`** + +Replace `Ok(TimsDataset { ... })` at the end of `open()`: + +```rust + Ok(TimsDataset { + reader, + mz_buf: Vec::new(), + int_buf: Vec::new(), + frame_tof_buf: Vec::new(), + frame_int_buf: Vec::new(), + frame_scan_offset_buf: Vec::new(), + frame_reader, + mz_converter, + im_converter, + swath_windows, + last_error: None, + rt_index, + }) +``` + +- [ ] **Step 7: Update `num_frames()` to use frame_reader** + +Replace the `num_frames` method: + +```rust + pub fn num_frames(&self) -> u32 { + self.frame_reader.len() as u32 + } +``` + +Remove the `num_frames` field comment if present. + +- [ ] **Step 8: Add get_frame method** + +Add after the `get_swath_windows` method: + +```rust + /// Get a single frame by index. Buffers are handle-owned, + /// valid until the next call to get_frame on this handle. + pub fn get_frame(&mut self, index: u32, out: &mut TimsFfiFrame) + -> Result<(), TimsFfiStatus> + { + let len = self.frame_reader.len() as u32; + if index >= len { + return Err(TimsFfiStatus::IndexOutOfBounds); + } + + #[cfg(feature = "with_timsrust")] + { + let frame = self.frame_reader.get(index as usize) + .map_err(|_| TimsFfiStatus::IndexOutOfBounds)?; + + self.frame_tof_buf.clear(); + self.frame_tof_buf.extend_from_slice(&frame.tof_indices); + + self.frame_int_buf.clear(); + self.frame_int_buf.extend_from_slice(&frame.intensities); + + self.frame_scan_offset_buf.clear(); + self.frame_scan_offset_buf.extend(frame.scan_offsets.iter().map(|&s| s as u64)); + + let num_scans = if frame.scan_offsets.is_empty() { + 0 + } else { + (frame.scan_offsets.len() - 1) as u32 + }; + + out.index = frame.index as u32; + out.rt_seconds = frame.rt_in_seconds; + out.ms_level = match frame.ms_level { + timsrust::MSLevel::MS1 => 1, + timsrust::MSLevel::MS2 => 2, + _ => 0, + }; + out.num_scans = num_scans; + out.num_peaks = frame.tof_indices.len() as u32; + out.tof_indices = if out.num_peaks == 0 { ptr::null() } else { self.frame_tof_buf.as_ptr() }; + out.intensities = if out.num_peaks == 0 { ptr::null() } else { self.frame_int_buf.as_ptr() }; + out.scan_offsets = if num_scans == 0 { ptr::null() } else { self.frame_scan_offset_buf.as_ptr() }; + + return Ok(()); + } + + #[cfg(not(feature = "with_timsrust"))] + { + out.index = index; + out.rt_seconds = 0.0; + out.ms_level = 0; + out.num_scans = 0; + out.num_peaks = 0; + out.tof_indices = ptr::null(); + out.intensities = ptr::null(); + out.scan_offsets = ptr::null(); + Ok(()) + } + } +``` + +Note: The `match frame.ms_level` may need adjustment depending on the exact `MSLevel` enum variants in timsrust 0.4.2. Use a wildcard `_` for any unknown variants. + +- [ ] **Step 9: Verify stub build compiles** + +Run: `cargo check` +Expected: PASS + +- [ ] **Step 10: Commit** + +```bash +git add src/types.rs src/dataset.rs +git commit -m "feat: add TimsFfiFrame type, FrameReader, and converters to TimsDataset" +``` + +### Task 6: Add frame FFI exports in lib.rs + +**Files:** +- Modify: `src/lib.rs` + +- [ ] **Step 1: Add import for TimsFfiFrame** + +Update the `use crate::types` import at the top of `lib.rs`: + +```rust +use crate::types::{TimsFfiSpectrum, TimsFfiStatus, TimsFfiFileInfo, TimsFfiLevelStats, TimsFfiFrame}; +``` + +- [ ] **Step 2: Add tims_get_frame FFI function** + +Append after the `tims_file_info` function: + +```rust +#[no_mangle] +pub extern "C" fn tims_get_frame( + handle: *mut tims_dataset, + index: c_uint, + out_frame: *mut TimsFfiFrame, +) -> TimsFfiStatus { + if handle.is_null() || out_frame.is_null() { + return TimsFfiStatus::Internal; + } + let ds = unsafe { &mut (*handle).inner }; + let out = unsafe { &mut *out_frame }; + match ds.get_frame(index, out) { + Ok(()) => TimsFfiStatus::Ok, + Err(e) => e, + } +} +``` + +- [ ] **Step 3: Add tims_get_frames_by_level FFI function** + +```rust +#[no_mangle] +pub extern "C" fn tims_get_frames_by_level( + handle: *mut tims_dataset, + ms_level: u8, + out_frames: *mut *mut TimsFfiFrame, + out_count: *mut c_uint, +) -> TimsFfiStatus { + if handle.is_null() || out_frames.is_null() || out_count.is_null() { + return TimsFfiStatus::Internal; + } + + // Invalid ms_level → empty result + if ms_level != 1 && ms_level != 2 { + unsafe { *out_count = 0; *out_frames = std::ptr::null_mut(); } + return TimsFfiStatus::Ok; + } + + #[cfg(not(feature = "with_timsrust"))] + { + unsafe { *out_count = 0; *out_frames = std::ptr::null_mut(); } + return TimsFfiStatus::Ok; + } + + #[cfg(feature = "with_timsrust")] + { + let ds = unsafe { &mut (*handle).inner }; + + let frames_result = if ms_level == 1 { + ds.frame_reader.get_all_ms1() + } else { + ds.frame_reader.get_all_ms2() + }; + + // Collect frames, failing on any read error + let frames: Vec<_> = match frames_result.into_iter().collect::, _>>() { + Ok(v) => v, + Err(_) => { + ds.last_error = Some("failed to read one or more frames".to_string()); + unsafe { *out_count = 0; *out_frames = std::ptr::null_mut(); } + return TimsFfiStatus::Internal; + } + }; + let n = frames.len(); + + if n == 0 { + unsafe { *out_count = 0; *out_frames = std::ptr::null_mut(); } + return TimsFfiStatus::Ok; + } + + // Allocate output array + let arr_ptr = unsafe { malloc(n * mem::size_of::()) } as *mut TimsFfiFrame; + if arr_ptr.is_null() { + return TimsFfiStatus::Internal; + } + + for (i, frame) in frames.iter().enumerate() { + let num_scans = if frame.scan_offsets.is_empty() { + 0u32 + } else { + (frame.scan_offsets.len() - 1) as u32 + }; + let n_peaks = frame.tof_indices.len(); + + // Allocate per-frame arrays via malloc + let tof_ptr = if n_peaks == 0 { std::ptr::null_mut() } else { + unsafe { malloc(n_peaks * mem::size_of::()) } as *mut u32 + }; + let int_ptr = if n_peaks == 0 { std::ptr::null_mut() } else { + unsafe { malloc(n_peaks * mem::size_of::()) } as *mut u32 + }; + let scan_len = frame.scan_offsets.len(); + let scan_ptr = if scan_len == 0 { std::ptr::null_mut() } else { + unsafe { malloc(scan_len * mem::size_of::()) } as *mut u64 + }; + + // Check allocations + if (n_peaks > 0 && (tof_ptr.is_null() || int_ptr.is_null())) + || (scan_len > 0 && scan_ptr.is_null()) + { + // Free already-allocated frames + for j in 0..i { + unsafe { + let old = arr_ptr.add(j).read(); + if !old.tof_indices.is_null() { free(old.tof_indices as *mut libc::c_void); } + if !old.intensities.is_null() { free(old.intensities as *mut libc::c_void); } + if !old.scan_offsets.is_null() { free(old.scan_offsets as *mut libc::c_void); } + } + } + if !tof_ptr.is_null() { unsafe { free(tof_ptr as *mut libc::c_void); } } + if !int_ptr.is_null() { unsafe { free(int_ptr as *mut libc::c_void); } } + if !scan_ptr.is_null() { unsafe { free(scan_ptr as *mut libc::c_void); } } + unsafe { free(arr_ptr as *mut libc::c_void); } + return TimsFfiStatus::Internal; + } + + // Copy data + if n_peaks > 0 { + unsafe { + std::ptr::copy_nonoverlapping(frame.tof_indices.as_ptr(), tof_ptr, n_peaks); + std::ptr::copy_nonoverlapping(frame.intensities.as_ptr(), int_ptr, n_peaks); + } + } + if scan_len > 0 { + for (k, &offset) in frame.scan_offsets.iter().enumerate() { + unsafe { *scan_ptr.add(k) = offset as u64; } + } + } + + let ms_lvl: u8 = match frame.ms_level { + timsrust::MSLevel::MS1 => 1, + timsrust::MSLevel::MS2 => 2, + _ => 0, + }; + + let out_frame = TimsFfiFrame { + index: frame.index as u32, + rt_seconds: frame.rt_in_seconds, + ms_level: ms_lvl, + num_scans, + num_peaks: n_peaks as u32, + tof_indices: if tof_ptr.is_null() { std::ptr::null() } else { tof_ptr }, + intensities: if int_ptr.is_null() { std::ptr::null() } else { int_ptr }, + scan_offsets: if scan_ptr.is_null() { std::ptr::null() } else { scan_ptr }, + }; + unsafe { arr_ptr.add(i).write(out_frame); } + } + + unsafe { *out_count = n as c_uint; *out_frames = arr_ptr; } + TimsFfiStatus::Ok + } +} +``` + +- [ ] **Step 4: Add tims_free_frame_array FFI function** + +```rust +#[no_mangle] +pub extern "C" fn tims_free_frame_array( + _handle: *mut tims_dataset, + frames: *mut TimsFfiFrame, + count: c_uint, +) { + if frames.is_null() { return; } + let n = count as usize; + for i in 0..n { + unsafe { + let f = frames.add(i).read(); + if !f.tof_indices.is_null() { free(f.tof_indices as *mut libc::c_void); } + if !f.intensities.is_null() { free(f.intensities as *mut libc::c_void); } + if !f.scan_offsets.is_null() { free(f.scan_offsets as *mut libc::c_void); } + } + } + unsafe { free(frames as *mut libc::c_void); } +} +``` + +- [ ] **Step 5: Verify stub build compiles** + +Run: `cargo check` +Expected: PASS + +- [ ] **Step 6: Also verify with timsrust feature (if available)** + +Run: `cargo check --features with_timsrust` +Expected: PASS (confirms `FrameReader::get`, `get_all_ms1`/`get_all_ms2`, `Frame` field names). If this fails due to missing timsrust dependency, defer to CI. + +- [ ] **Step 7: Update C header with frame types and functions** + +In `include/timsrust_cpp_bridge.h`, add after the `tims_swath_window` typedef: + +```c +typedef struct { + uint32_t index; /* frame index */ + double rt_seconds; /* retention time in seconds */ + uint8_t ms_level; /* 1=MS1, 2=MS2, 0=Unknown */ + uint32_t num_scans; /* number of scans */ + uint32_t num_peaks; /* total peaks (length of tof_indices & intensities) */ + const uint32_t *tof_indices; /* raw TOF indices, flat array */ + const uint32_t *intensities; /* raw intensities, flat array */ + const uint64_t *scan_offsets; /* per-scan offsets (length: num_scans + 1) */ +} tims_frame; +``` + +And add the function declarations before the `#ifdef __cplusplus` closing: + +```c +/* ------------------------------------------------------------------------- + * Frame-level access (raw TOF indices, not converted to m/z) + * ------------------------------------------------------------------------- */ + +/* Get a single frame by index. Buffers are handle-owned, valid until the + * next call to tims_get_frame on the same handle. Frame and spectrum + * buffers are independent. */ +timsffi_status tims_get_frame(tims_dataset* handle, uint32_t index, tims_frame* out); + +/* Get all frames at a given MS level (1=MS1, 2=MS2). + * Returns malloc'd array; free with tims_free_frame_array. + * Invalid ms_level returns empty array with TIMSFFI_OK. */ +timsffi_status tims_get_frames_by_level(tims_dataset* handle, uint8_t ms_level, + tims_frame** out_frames, uint32_t* out_count); + +/* Free frames allocated by tims_get_frames_by_level. Frees per-frame + * tof_indices, intensities, scan_offsets arrays, then the array itself. */ +void tims_free_frame_array(tims_dataset* handle, tims_frame* frames, uint32_t count); +``` + +- [ ] **Step 8: Commit** + +```bash +git add src/lib.rs include/timsrust_cpp_bridge.h +git commit -m "feat: add tims_get_frame, tims_get_frames_by_level, tims_free_frame_array FFI exports" +``` + +--- + +## Chunk 3: Converters and Config + +### Task 7: Add converter FFI exports + +**Files:** +- Modify: `src/lib.rs` + +- [ ] **Step 1: Add single-value converter functions** + +Append to `src/lib.rs`: + +```rust +#[no_mangle] +pub extern "C" fn tims_convert_tof_to_mz( + handle: *const tims_dataset, + tof_index: c_uint, +) -> c_double { + if handle.is_null() { return f64::NAN; } + let ds = unsafe { &(*handle).inner }; + ds.mz_converter.convert(tof_index as f64) +} + +#[no_mangle] +pub extern "C" fn tims_convert_scan_to_im( + handle: *const tims_dataset, + scan_index: c_uint, +) -> c_double { + if handle.is_null() { return f64::NAN; } + let ds = unsafe { &(*handle).inner }; + ds.im_converter.convert(scan_index as f64) +} +``` + +Note: For the `with_timsrust` build, `ConvertableDomain::convert()` is a trait method already imported in `dataset.rs`. Since `lib.rs` accesses the converters via `ds.mz_converter` and `ds.im_converter`, we need the trait in scope. Add at the top of `lib.rs`: + +```rust +#[cfg(feature = "with_timsrust")] +use timsrust::converters::ConvertableDomain; +``` + +- [ ] **Step 2: Add batch converter functions** + +```rust +#[no_mangle] +pub extern "C" fn tims_convert_tof_to_mz_array( + handle: *const tims_dataset, + tof_indices: *const u32, + count: c_uint, + out_mz: *mut c_double, +) -> TimsFfiStatus { + if handle.is_null() || tof_indices.is_null() || out_mz.is_null() { + return TimsFfiStatus::Internal; + } + let ds = unsafe { &(*handle).inner }; + let n = count as usize; + for i in 0..n { + let idx = unsafe { *tof_indices.add(i) }; + unsafe { *out_mz.add(i) = ds.mz_converter.convert(idx as f64); } + } + TimsFfiStatus::Ok +} + +#[no_mangle] +pub extern "C" fn tims_convert_scan_to_im_array( + handle: *const tims_dataset, + scan_indices: *const u32, + count: c_uint, + out_im: *mut c_double, +) -> TimsFfiStatus { + if handle.is_null() || scan_indices.is_null() || out_im.is_null() { + return TimsFfiStatus::Internal; + } + let ds = unsafe { &(*handle).inner }; + let n = count as usize; + for i in 0..n { + let idx = unsafe { *scan_indices.add(i) }; + unsafe { *out_im.add(i) = ds.im_converter.convert(idx as f64); } + } + TimsFfiStatus::Ok +} +``` + +- [ ] **Step 3: Verify stub build compiles** + +Run: `cargo check` +Expected: PASS — the stub `Tof2MzConverter` and `Scan2ImConverter` have a `convert` method. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib.rs +git commit -m "feat: add tims_convert_tof_to_mz and tims_convert_scan_to_im FFI exports" +``` + +### Task 8: Add config module + +**Files:** +- Create: `src/config.rs` +- Modify: `src/lib.rs` + +- [ ] **Step 1: Create src/config.rs** + +```rust +// src/config.rs +// +// Opaque config builder wrapping timsrust's SpectrumReaderConfig. +// Used by tims_open_with_config() to customize reader construction. + +#[cfg(feature = "with_timsrust")] +use timsrust::readers::SpectrumReaderConfig; + +/// FFI-facing config wrapper. When with_timsrust is enabled, wraps the +/// real SpectrumReaderConfig. Otherwise a dummy struct for API compat. +pub struct TimsFfiConfig { + #[cfg(feature = "with_timsrust")] + pub(crate) inner: SpectrumReaderConfig, + + #[cfg(not(feature = "with_timsrust"))] + _dummy: (), +} + +impl TimsFfiConfig { + pub fn new() -> Self { + TimsFfiConfig { + #[cfg(feature = "with_timsrust")] + inner: SpectrumReaderConfig::default(), + + #[cfg(not(feature = "with_timsrust"))] + _dummy: (), + } + } + + #[cfg(feature = "with_timsrust")] + pub fn set_smoothing_window(&mut self, window: u32) { + self.inner.spectrum_processing_params.smoothing_window = window; + } + + #[cfg(not(feature = "with_timsrust"))] + pub fn set_smoothing_window(&mut self, _window: u32) {} + + #[cfg(feature = "with_timsrust")] + pub fn set_centroiding_window(&mut self, window: u32) { + self.inner.spectrum_processing_params.centroiding_window = window; + } + + #[cfg(not(feature = "with_timsrust"))] + pub fn set_centroiding_window(&mut self, _window: u32) {} + + #[cfg(feature = "with_timsrust")] + pub fn set_calibration_tolerance(&mut self, tolerance: f64) { + self.inner.spectrum_processing_params.calibration_tolerance = tolerance; + } + + #[cfg(not(feature = "with_timsrust"))] + pub fn set_calibration_tolerance(&mut self, _tolerance: f64) {} + + #[cfg(feature = "with_timsrust")] + pub fn set_calibrate(&mut self, enabled: bool) { + self.inner.spectrum_processing_params.calibrate = enabled; + } + + #[cfg(not(feature = "with_timsrust"))] + pub fn set_calibrate(&mut self, _enabled: bool) {} +} +``` + +Note: The exact field names on `SpectrumProcessingParams` (`smoothing_window`, `centroiding_window`, `calibration_tolerance`, `calibrate`) must be verified against timsrust 0.4.2 at build time. If the field names differ, adjust accordingly. The `FrameWindowSplittingConfiguration` setters are deferred (TBD in spec). + +- [ ] **Step 2: Register config module in lib.rs** + +Add `mod config;` at the top of `src/lib.rs` alongside `mod dataset;` and `mod types;`: + +```rust +mod config; +mod dataset; +mod types; +``` + +- [ ] **Step 3: Add config FFI exports to lib.rs** + +Add the `use` import and FFI functions: + +```rust +use crate::config::TimsFfiConfig; +``` + +Then add the FFI functions: + +```rust +/// Opaque config type for C callers. +#[repr(C)] +pub struct tims_config { + inner: TimsFfiConfig, +} + +#[no_mangle] +pub extern "C" fn tims_config_create() -> *mut tims_config { + let cfg = Box::new(tims_config { + inner: TimsFfiConfig::new(), + }); + Box::into_raw(cfg) +} + +#[no_mangle] +pub extern "C" fn tims_config_free(cfg: *mut tims_config) { + if cfg.is_null() { return; } + unsafe { drop(Box::from_raw(cfg)); } +} + +#[no_mangle] +pub extern "C" fn tims_config_set_smoothing_window(cfg: *mut tims_config, window: c_uint) { + if cfg.is_null() { return; } + let c = unsafe { &mut (*cfg).inner }; + c.set_smoothing_window(window); +} + +#[no_mangle] +pub extern "C" fn tims_config_set_centroiding_window(cfg: *mut tims_config, window: c_uint) { + if cfg.is_null() { return; } + let c = unsafe { &mut (*cfg).inner }; + c.set_centroiding_window(window); +} + +#[no_mangle] +pub extern "C" fn tims_config_set_calibration_tolerance(cfg: *mut tims_config, tolerance: c_double) { + if cfg.is_null() { return; } + let c = unsafe { &mut (*cfg).inner }; + c.set_calibration_tolerance(tolerance); +} + +#[no_mangle] +pub extern "C" fn tims_config_set_calibrate(cfg: *mut tims_config, enabled: u8) { + if cfg.is_null() { return; } + let c = unsafe { &mut (*cfg).inner }; + c.set_calibrate(enabled != 0); +} +``` + +- [ ] **Step 4: Add tims_open_with_config FFI function** + +This function also needs a corresponding `open_with_config` method on `TimsDataset` in `dataset.rs`, or we can handle the builder pattern directly in `lib.rs`. Since the config affects only the `SpectrumReader` construction (via builder), add the logic in `lib.rs` (similar to `tims_open`) and add an `open_with_config` to `dataset.rs`. + +First, add to `src/dataset.rs`: + +```rust + #[cfg(feature = "with_timsrust")] + pub fn open_with_config(path: &CStr, config: &TimsFfiConfig) -> Result { + let path_str = path.to_str().map_err(|_| TimsFfiStatus::InvalidUtf8)?; + + let reader = timsrust::readers::SpectrumReader::build() + .with_path(path_str) + .with_config(config.inner.clone()) + .finalize() + .map_err(|_| TimsFfiStatus::OpenFailed)?; + + // Rest is same as open() — extract frame_reader, converters, swath windows, RT index + // Factor out the common post-reader setup + Self::finish_open(path_str, reader) + } +``` + +This means we should refactor `open()` to share common setup. Add a helper: + +In `dataset.rs`, under `#[cfg(feature = "with_timsrust")]`, add a private helper after the struct: + +```rust + #[cfg(feature = "with_timsrust")] + fn finish_open(path_str: &str, reader: SpectrumReader) -> Result { + use timsrust::readers::QuadrupoleSettingsReader; + use timsrust::readers::PrecursorReader; + + let fr = FrameReader::new(path_str) + .map_err(|_| TimsFfiStatus::OpenFailed)?; + + let metadata = MetadataReader::new(path_str) + .map_err(|_| TimsFfiStatus::OpenFailed)?; + let mz_conv = metadata.mz_converter; + let im_conv = metadata.im_converter; + + let mut sw_out: Vec = Vec::new(); + if let Ok(quads) = QuadrupoleSettingsReader::new(path_str) { + for quad in quads.iter() { + for i in 0..quad.len() { + let center = quad.isolation_mz[i]; + let width = quad.isolation_width[i]; + let mz_lower = center - width / 2.0; + let mz_upper = center + width / 2.0; + let (im_lower, im_upper) = if i < quad.scan_starts.len() && i < quad.scan_ends.len() { + let start = quad.scan_starts[i] as f64; + let end = quad.scan_ends[i] as f64; + let im_start = im_conv.convert(start); + let im_end = im_conv.convert(end); + (im_start.min(im_end), im_start.max(im_end)) + } else { + (metadata.lower_im, metadata.upper_im) + }; + sw_out.push(TimsFfiSwathWindow { + mz_lower, + mz_upper, + mz_center: center, + im_lower, + im_upper, + is_ms1: 0, + }); + } + } + } + let swath_windows = if sw_out.is_empty() { None } else { Some(sw_out) }; + + // Build RT index + let mut rt_index: Vec<(f64, usize)> = Vec::new(); + if let Ok(prec_reader) = PrecursorReader::build() + .with_path(path_str) + .finalize() + { + let n = prec_reader.len(); + rt_index.reserve(n); + for i in 0..n { + if let Some(p) = prec_reader.get(i) { + rt_index.push((p.rt, i)); + } + } + rt_index.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + } + + Ok(TimsDataset { + reader, + mz_buf: Vec::new(), + int_buf: Vec::new(), + frame_tof_buf: Vec::new(), + frame_int_buf: Vec::new(), + frame_scan_offset_buf: Vec::new(), + frame_reader: fr, + mz_converter: mz_conv, + im_converter: im_conv, + swath_windows, + last_error: None, + rt_index, + }) + } +``` + +Then simplify `open()` for the `with_timsrust` branch: + +```rust + pub fn open(path: &CStr) -> Result { + let path_str = path.to_str().map_err(|_| TimsFfiStatus::InvalidUtf8)?; + + #[cfg(feature = "with_timsrust")] + { + let reader = SpectrumReader::new(path_str) + .map_err(|_| TimsFfiStatus::OpenFailed)?; + return Self::finish_open(path_str, reader); + } + + #[cfg(not(feature = "with_timsrust"))] + { + let reader = SpectrumReader::new(path) + .map_err(|_| TimsFfiStatus::OpenFailed)?; + let frame_reader = FrameReader::new("").map_err(|_| TimsFfiStatus::OpenFailed)?; + Ok(TimsDataset { + reader, + mz_buf: Vec::new(), + int_buf: Vec::new(), + frame_tof_buf: Vec::new(), + frame_int_buf: Vec::new(), + frame_scan_offset_buf: Vec::new(), + frame_reader, + mz_converter: Tof2MzConverter, + im_converter: Scan2ImConverter, + swath_windows: None, + last_error: None, + rt_index: Vec::new(), + }) + } + } + + #[cfg(feature = "with_timsrust")] + pub fn open_with_config(path: &CStr, config: &crate::config::TimsFfiConfig) -> Result { + let path_str = path.to_str().map_err(|_| TimsFfiStatus::InvalidUtf8)?; + let reader = timsrust::readers::SpectrumReader::build() + .with_path(path_str) + .with_config(config.inner.clone()) + .finalize() + .map_err(|_| TimsFfiStatus::OpenFailed)?; + Self::finish_open(path_str, reader) + } + + #[cfg(not(feature = "with_timsrust"))] + pub fn open_with_config(path: &CStr, _config: &crate::config::TimsFfiConfig) -> Result { + Self::open(path) + } +``` + +- [ ] **Step 5: Add tims_open_with_config FFI function in lib.rs** + +```rust +#[no_mangle] +pub extern "C" fn tims_open_with_config( + path: *const c_char, + cfg: *const tims_config, + out_handle: *mut *mut tims_dataset, +) -> TimsFfiStatus { + if path.is_null() || cfg.is_null() || out_handle.is_null() { + return TimsFfiStatus::Internal; + } + let cstr = unsafe { CStr::from_ptr(path) }; + let path_str = match cstr.to_str() { + Ok(s) => s, + Err(_) => return TimsFfiStatus::InvalidUtf8, + }; + if std::fs::metadata(path_str).is_err() { + if let Ok(mut g) = LAST_ERROR.lock() { *g = Some(format!("path not found: {}", path_str)); } + return TimsFfiStatus::OpenFailed; + } + + let path_owned = path_str.to_string(); + + let (tx, rx) = mpsc::channel(); + // Clone the inner SpectrumReaderConfig for the thread. + // SpectrumReaderConfig derives Clone/Copy in timsrust 0.4.2. + #[cfg(feature = "with_timsrust")] + let config_inner = unsafe { (*cfg).inner.inner.clone() }; + + let thr = thread::spawn(move || { + let c = CString::new(path_owned).unwrap(); + let res = std::panic::catch_unwind(|| { + TimsDataset::open_with_config(c.as_c_str(), &{ + let mut cfg = crate::config::TimsFfiConfig::new(); + #[cfg(feature = "with_timsrust")] + { cfg.inner = config_inner; } + cfg + }) + }); + match res { + Ok(Ok(inner)) => { let _ = tx.send(Ok(inner)); } + Ok(Err(e)) => { let _ = tx.send(Err(format!("open error: {:?}", e))); } + Err(p) => { + let msg = if let Some(s) = p.downcast_ref::<&str>() { s.to_string() } + else if let Some(s) = p.downcast_ref::() { s.clone() } + else { "panic during open".to_string() }; + let _ = tx.send(Err(format!("panic: {}", msg))); + } + } + }); + + let got = match thr.join() { + Ok(_) => rx.recv(), + Err(_) => Err(std::sync::mpsc::RecvError), + }; + + match got { + Ok(Ok(inner)) => { + let boxed = Box::new(tims_dataset { inner }); + unsafe { *out_handle = Box::into_raw(boxed); } + if let Ok(mut g) = LAST_ERROR.lock() { *g = None; } + TimsFfiStatus::Ok + } + Ok(Err(msg)) => { + if let Ok(mut g) = LAST_ERROR.lock() { *g = Some(msg); } + TimsFfiStatus::OpenFailed + } + Err(_) => { + if let Ok(mut g) = LAST_ERROR.lock() { *g = Some("open join/recv failed".to_string()); } + TimsFfiStatus::OpenFailed + } + } +} +``` + +- [ ] **Step 6: Verify stub build compiles** + +Run: `cargo check` +Expected: PASS + +- [ ] **Step 7: Also verify with timsrust feature (if available)** + +Run: `cargo check --features with_timsrust` +Expected: PASS (confirms `SpectrumReaderConfig`, `SpectrumProcessingParams` field names, `ConvertableDomain::convert`). If this fails due to missing timsrust dependency, defer to CI. + +- [ ] **Step 8: Update C header with converter and config declarations** + +In `include/timsrust_cpp_bridge.h`, add before the `#ifdef __cplusplus` closing: + +```c +/* ------------------------------------------------------------------------- + * Index converters (TOF -> m/z, scan -> ion mobility) + * ------------------------------------------------------------------------- */ + +/* Convert a single TOF index to m/z. Returns NaN if handle is NULL. */ +double tims_convert_tof_to_mz(const tims_dataset* handle, uint32_t tof_index); + +/* Convert a single scan index to ion mobility (1/K0). Returns NaN if handle is NULL. */ +double tims_convert_scan_to_im(const tims_dataset* handle, uint32_t scan_index); + +/* Batch convert TOF indices to m/z. Caller provides output buffer. */ +timsffi_status tims_convert_tof_to_mz_array(const tims_dataset* handle, + const uint32_t* tof_indices, uint32_t count, + double* out_mz); + +/* Batch convert scan indices to ion mobility. Caller provides output buffer. */ +timsffi_status tims_convert_scan_to_im_array(const tims_dataset* handle, + const uint32_t* scan_indices, uint32_t count, + double* out_im); + +/* ------------------------------------------------------------------------- + * Opaque configuration for SpectrumReader construction + * ------------------------------------------------------------------------- */ + +typedef struct tims_config tims_config; + +/* Create a new config with default values. Caller must free with tims_config_free. */ +tims_config *tims_config_create(void); + +/* Free a config created by tims_config_create. */ +void tims_config_free(tims_config *cfg); + +/* SpectrumProcessingParams setters */ +void tims_config_set_smoothing_window(tims_config *cfg, uint32_t window); +void tims_config_set_centroiding_window(tims_config *cfg, uint32_t window); +void tims_config_set_calibration_tolerance(tims_config *cfg, double tolerance); +void tims_config_set_calibrate(tims_config *cfg, uint8_t enabled); /* 0=off, non-zero=on */ + +/* Open dataset with custom config. Existing tims_open uses defaults. */ +timsffi_status tims_open_with_config(const char* path, const tims_config* cfg, tims_dataset** out); +``` + +- [ ] **Step 9: Commit** + +```bash +git add src/config.rs src/dataset.rs src/lib.rs include/timsrust_cpp_bridge.h +git commit -m "feat: add config builder, tims_open_with_config, and converter FFI exports" +``` + +--- + +## Chunk 4: C++ Example + +### Task 9: Update C++ example + +**Files:** +- Modify: `examples/cpp_client.cpp` + +- [ ] **Step 1: Add frame and converter demonstration** + +Add a new section after the file info output (before `tims_close`). This demonstrates frame access, converters, and the new spectrum fields: + +```cpp + // ---- Frame-level access demo -------------------------------------------- + std::cout << "\n-- Frame-level access --\n"; + unsigned int total_frames = tims_num_frames(handle); + if (total_frames > 0) { + tims_frame frame{}; + timsffi_status fs = tims_get_frame(handle, 0, &frame); + if (fs == TIMSFFI_OK) { + std::cout << "Frame 0: index=" << frame.index + << " rt=" << std::fixed << std::setprecision(2) << frame.rt_seconds << "s" + << " ms_level=" << (int)frame.ms_level + << " scans=" << frame.num_scans + << " peaks=" << frame.num_peaks << "\n"; + } + + // Batch: get all MS1 frames + tims_frame* ms1_frames = nullptr; + unsigned int ms1_count = 0; + auto t_ms1 = Clock::now(); + tims_get_frames_by_level(handle, 1, &ms1_frames, &ms1_count); + double ms1_ms = elapsed_ms(t_ms1); + std::cout << "MS1 frames: " << ms1_count + << " (fetched in " << std::setprecision(1) << ms1_ms << " ms)\n"; + if (ms1_frames) tims_free_frame_array(handle, ms1_frames, ms1_count); + } + + // ---- Converter demo ----------------------------------------------------- + std::cout << "\n-- Converters --\n"; + double mz_example = tims_convert_tof_to_mz(handle, 100000); + double im_example = tims_convert_scan_to_im(handle, 500); + std::cout << "TOF 100000 -> m/z " << std::setprecision(4) << mz_example << "\n"; + std::cout << "Scan 500 -> IM " << std::setprecision(4) << im_example << "\n"; + + // ---- Extended spectrum fields demo -------------------------------------- + std::cout << "\n-- Extended spectrum fields --\n"; + if (tims_num_spectra(handle) > 0) { + tims_spectrum spec{}; + if (tims_get_spectrum(handle, 0, &spec) == TIMSFFI_OK) { + std::cout << "Spectrum 0: index=" << spec.index + << " ms_level=" << (int)spec.ms_level + << " charge=" << (int)spec.charge + << " isolation_width=" << std::setprecision(2) << spec.isolation_width + << " isolation_mz=" << spec.isolation_mz + << " frame_index=" << spec.frame_index + << " precursor_intensity="; + if (std::isnan(spec.precursor_intensity)) + std::cout << "N/A"; + else + std::cout << std::setprecision(0) << spec.precursor_intensity; + std::cout << "\n"; + } + } +``` + +- [ ] **Step 2: Verify stub build compiles** + +Run: `cargo check` +Expected: PASS (the C++ example is compiled separately, not by cargo) + +- [ ] **Step 3: Commit** + +```bash +git add examples/cpp_client.cpp +git commit -m "feat: update C++ example with frame, converter, and extended spectrum demos" +``` + +--- + +## Implementation Notes + +### Build verification + +Since there is no automated test suite, verification at each step uses: +- `cargo check` — fast type-check for the stub build (no `with_timsrust` feature) +- `cargo check --features with_timsrust` — type-check with real timsrust (requires the dependency to be available) +- `cargo build --release` — full stub build +- `cargo build --features with_timsrust --release` — full build with timsrust + +### timsrust API verification + +Some field names (`SpectrumProcessingParams.smoothing_window`, `Frame.rt_in_seconds`, `Spectrum.isolation_mz`, etc.) are based on the Sage analysis and timsrust 0.4.2 docs. If any field name doesn't match at compile time, check the actual timsrust source and adjust. Key types to verify: +- `timsrust::Frame` — fields: `tof_indices`, `intensities`, `scan_offsets`, `rt_in_seconds`, `index`, `ms_level` +- `timsrust::Spectrum` — fields: `isolation_width`, `isolation_mz`, `index` +- `timsrust::Precursor` — fields: `charge`, `intensity`, `frame_index` +- `timsrust::MSLevel` — variants: `MS1`, `MS2`, and possibly `Unknown` +- `timsrust::readers::SpectrumReaderConfig` — field: `spectrum_processing_params` +- `timsrust::SpectrumProcessingParams` — fields: `smoothing_window`, `centroiding_window`, `calibration_tolerance`, `calibrate` + +### FrameReader methods + +The plan uses `FrameReader::get(index)` for single-frame access and `FrameReader::get_all_ms1()` / `FrameReader::get_all_ms2()` for batch. Verify these methods exist in timsrust 0.4.2. If `get_all_ms1/ms2` don't exist, use `parallel_filter` with the appropriate predicate instead (requires rayon in scope). + +### ConvertableDomain trait + +The `convert()` method is from the `ConvertableDomain` trait. It must be in scope wherever `.convert()` is called. In `dataset.rs` it's already imported. In `lib.rs` it needs to be imported for the converter FFI functions if they call `.convert()` directly (which they do via `ds.mz_converter.convert()`). diff --git a/docs/superpowers/plans/2026-03-12-testing-strategy.md b/docs/superpowers/plans/2026-03-12-testing-strategy.md new file mode 100644 index 0000000..f893cf9 --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-testing-strategy.md @@ -0,0 +1,1956 @@ +# Testing Strategy Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement a contract-centric test suite for the timsrust_cpp_bridge FFI library. + +**Architecture:** Rust integration tests (`tests/`) call `extern "C"` functions directly in stub mode (no datasets) for FFI safety coverage. A separate `ffi_real_data.rs` module tests correctness with real `.d` datasets gated by env vars. A Catch2 C++ suite (`tests_cpp/`) validates ABI layout and end-to-end flows. + +**Tech Stack:** Rust `cargo test`, Catch2 v3 (single header), Make, g++ + +**Feature gate convention:** Stub-mode tests (Tasks 2-8) run with `cargo test` (no features). +Real-data tests (Task 9) run with `cargo test --features with_timsrust` plus env vars. +Tests with stub-specific assertions (identity converters, zero counts from `open_stub()`) +are gated behind `#[cfg(not(feature = "with_timsrust"))]` so they compile out when +the real reader is active. `open_stub()` only works without `with_timsrust` because +opening a non-`.d` temp directory fails with the real reader. + +--- + +## Chunk 1: Test Infrastructure & Lifecycle Tests + +### Task 0: Configure Cargo.toml for integration tests + +**Files:** +- Modify: `Cargo.toml` + +Integration tests require an `rlib` target to link against. The crate currently only +produces `cdylib` + `staticlib`. We also need `libc` as a dev-dependency for tests +that call `libc::malloc` directly. + +- [ ] **Step 1: Add `"lib"` to crate-type and `libc` to dev-dependencies** + +In `Cargo.toml`, change: +```toml +crate-type = ["cdylib", "staticlib"] +``` +to: +```toml +crate-type = ["cdylib", "staticlib", "lib"] +``` + +And add: +```toml +[dev-dependencies] +libc = "0.2" +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo check` + +- [ ] **Step 3: Commit** + +```bash +git add Cargo.toml +git commit -m "build: add rlib crate-type and libc dev-dep for integration tests" +``` + +### Task 1: Create test helpers module + +**Files:** +- Create: `tests/common/mod.rs` + +Since we added `"lib"` to crate-type, integration tests can import the crate's +actual types directly. This gives us type-safe FFI calls with correct `TimsFfiStatus` +return types and `*mut tims_dataset` handles. + +- [ ] **Step 1: Write the helpers module** + +```rust +// tests/common/mod.rs +// +// Shared helpers for FFI integration tests. +// Uses the crate's own types for type-safe FFI calls. + +use std::ffi::CString; +use std::ptr; + +// Re-export the crate's public FFI types and functions. +pub use timsrust_cpp_bridge::*; + +// Re-export types from the crate's types module. +use timsrust_cpp_bridge::types::{ + TimsFfiSpectrum, TimsFfiFrame, TimsFfiSwathWindow, + TimsFfiFileInfo, TimsFfiLevelStats, TimsFfiStatus, +}; + +// ---- Status code constants for convenience ---- + +pub const TIMSFFI_OK: TimsFfiStatus = TimsFfiStatus::Ok; +pub const TIMSFFI_ERR_INVALID_UTF8: TimsFfiStatus = TimsFfiStatus::InvalidUtf8; +pub const TIMSFFI_ERR_OPEN_FAILED: TimsFfiStatus = TimsFfiStatus::OpenFailed; +pub const TIMSFFI_ERR_INDEX_OOB: TimsFfiStatus = TimsFfiStatus::IndexOutOfBounds; +pub const TIMSFFI_ERR_INTERNAL: TimsFfiStatus = TimsFfiStatus::Internal; + +// ---- Helpers ---- + +/// Open a dataset against the stub build. Creates a temp directory +/// that exists on disk (stub open succeeds for any existing path). +pub fn open_stub() -> *mut tims_dataset { + let dir = std::env::temp_dir().join("timsrust_ffi_test_stub"); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let c_path = CString::new(dir.to_str().unwrap()).unwrap(); + let mut handle: *mut tims_dataset = ptr::null_mut(); + let status = unsafe { tims_open(c_path.as_ptr(), &mut handle) }; + assert_eq!(status, TIMSFFI_OK, "open_stub failed"); + assert!(!handle.is_null()); + handle +} + +/// Assert that a status code matches expected. +pub fn assert_status(actual: TimsFfiStatus, expected: TimsFfiStatus) { + assert_eq!( + actual, expected, + "expected status {expected:?}, got {actual:?}" + ); +} + +/// Returns DDA dataset path from env, or None. +pub fn dda_path() -> Option { + std::env::var("TIMSRUST_TEST_DATA_DDA").ok() +} + +/// Returns DIA dataset path from env, or None. +pub fn dia_path() -> Option { + std::env::var("TIMSRUST_TEST_DATA_DIA").ok() +} + +/// Open a real dataset by path. Panics on failure. +pub fn open_real(path: &str) -> *mut tims_dataset { + let c_path = CString::new(path).unwrap(); + let mut handle: *mut tims_dataset = ptr::null_mut(); + let status = unsafe { tims_open(c_path.as_ptr(), &mut handle) }; + assert_eq!(status, TIMSFFI_OK, "open_real failed for {path}"); + assert!(!handle.is_null()); + handle +} +``` + +**Important:** This approach imports the crate's own types. If the crate's FFI +functions and types are not publicly re-exported from `lib.rs`, you will need to +add `pub use` statements in `src/lib.rs` for the types module: +```rust +pub mod types; // (if not already public) +``` +Check `src/lib.rs` — if `mod types;` is private, change it to `pub mod types;` +and similarly for any types used in function signatures. The `tims_dataset` struct +and all `extern "C"` functions are already `pub`, so they should be accessible. +If the types module can't be made public, fall back to declaring `extern "C"` +blocks manually with `*mut std::ffi::c_void` for handles and `i32` for status +(with a compile-time size assertion on `TimsFfiStatus`). + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo test --no-run 2>&1 | head -20` + +This will fail because there are no test files importing the module yet. That's expected — we just need the module to exist. + +- [ ] **Step 3: Commit** + +```bash +git add tests/common/mod.rs +git commit -m "test: add shared FFI test helpers module" +``` + +### Task 2: Lifecycle tests — tims_open / tims_close + +**Files:** +- Create: `tests/ffi_lifecycle.rs` + +- [ ] **Step 1: Write the lifecycle test file** + +```rust +// tests/ffi_lifecycle.rs +mod common; + +use common::*; +use std::ffi::CString; +use std::ptr; + +#[test] +fn open_null_path_returns_internal() { + let mut handle: *mut tims_dataset = ptr::null_mut(); + let status = unsafe { tims_open(ptr::null(), &mut handle) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +#[test] +fn open_null_out_handle_returns_internal() { + let path = CString::new("/tmp/dummy").unwrap(); + let status = unsafe { tims_open(path.as_ptr(), ptr::null_mut()) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +#[test] +fn open_nonexistent_path_returns_open_failed() { + let path = CString::new("/tmp/timsrust_ffi_test_nonexistent_path_12345").unwrap(); + let mut handle: *mut tims_dataset = ptr::null_mut(); + let status = unsafe { tims_open(path.as_ptr(), &mut handle) }; + assert_status(status, TIMSFFI_ERR_OPEN_FAILED); +} + +#[test] +fn open_valid_stub_succeeds() { + let handle = open_stub(); + assert!(!handle.is_null()); + unsafe { tims_close(handle); } +} + +#[test] +fn close_null_handle_no_crash() { + unsafe { tims_close(ptr::null_mut()); } +} + +#[test] +fn close_valid_handle_no_crash() { + let handle = open_stub(); + unsafe { tims_close(handle); } + // If we reach here without crashing, test passes. +} +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `cargo test --test ffi_lifecycle -- --nocapture` +Expected: All 6 tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add tests/ffi_lifecycle.rs +git commit -m "test: add FFI lifecycle tests (open, close, null safety)" +``` + +### Task 3: Lifecycle tests — config builder & open_with_config + +**Files:** +- Modify: `tests/ffi_lifecycle.rs` + +- [ ] **Step 1: Add config builder and open_with_config tests** + +Append to `tests/ffi_lifecycle.rs`: + +```rust +#[test] +fn open_with_config_null_path_returns_internal() { + let cfg = unsafe { tims_config_create() }; + let mut handle: *mut tims_dataset = ptr::null_mut(); + let status = unsafe { tims_open_with_config(ptr::null(), cfg, &mut handle) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); + unsafe { tims_config_free(cfg); } +} + +#[test] +fn open_with_config_null_config_returns_internal() { + let path = CString::new("/tmp/dummy").unwrap(); + let mut handle: *mut tims_dataset = ptr::null_mut(); + let status = unsafe { tims_open_with_config(path.as_ptr(), ptr::null(), &mut handle) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +#[test] +fn open_with_config_null_out_handle_returns_internal() { + let path = CString::new("/tmp/dummy").unwrap(); + let cfg = unsafe { tims_config_create() }; + let status = unsafe { tims_open_with_config(path.as_ptr(), cfg, ptr::null_mut()) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); + unsafe { tims_config_free(cfg); } +} + +#[test] +fn open_with_config_nonexistent_path_returns_open_failed() { + let path = CString::new("/tmp/timsrust_ffi_test_nonexistent_12345").unwrap(); + let cfg = unsafe { tims_config_create() }; + let mut handle: *mut tims_dataset = ptr::null_mut(); + let status = unsafe { tims_open_with_config(path.as_ptr(), cfg, &mut handle) }; + assert_status(status, TIMSFFI_ERR_OPEN_FAILED); + unsafe { tims_config_free(cfg); } +} + +#[test] +fn open_with_config_happy_path() { + let dir = std::env::temp_dir().join("timsrust_ffi_test_stub_cfg"); + std::fs::create_dir_all(&dir).unwrap(); + let path = CString::new(dir.to_str().unwrap()).unwrap(); + let cfg = unsafe { tims_config_create() }; + let mut handle: *mut tims_dataset = ptr::null_mut(); + let status = unsafe { tims_open_with_config(path.as_ptr(), cfg, &mut handle) }; + assert_status(status, TIMSFFI_OK); + assert!(!handle.is_null()); + unsafe { + tims_config_free(cfg); + tims_close(handle); + } +} + +#[test] +fn config_create_returns_non_null() { + let cfg = unsafe { tims_config_create() }; + assert!(!cfg.is_null()); + unsafe { tims_config_free(cfg); } +} + +#[test] +fn config_free_null_no_crash() { + unsafe { tims_config_free(ptr::null_mut()); } +} + +#[test] +fn config_free_valid_no_crash() { + let cfg = unsafe { tims_config_create() }; + unsafe { tims_config_free(cfg); } +} + +#[test] +fn config_setters_null_no_crash() { + unsafe { + tims_config_set_smoothing_window(ptr::null_mut(), 5); + tims_config_set_centroiding_window(ptr::null_mut(), 5); + tims_config_set_calibration_tolerance(ptr::null_mut(), 0.01); + tims_config_set_calibrate(ptr::null_mut(), 1); + } +} + +#[test] +fn config_builder_full_lifecycle() { + let dir = std::env::temp_dir().join("timsrust_ffi_test_cfg_lifecycle"); + std::fs::create_dir_all(&dir).unwrap(); + let path = CString::new(dir.to_str().unwrap()).unwrap(); + + let cfg = unsafe { tims_config_create() }; + unsafe { + tims_config_set_smoothing_window(cfg, 3); + tims_config_set_centroiding_window(cfg, 5); + tims_config_set_calibration_tolerance(cfg, 0.01); + tims_config_set_calibrate(cfg, 1); + } + let mut handle: *mut tims_dataset = ptr::null_mut(); + let status = unsafe { tims_open_with_config(path.as_ptr(), cfg, &mut handle) }; + assert_status(status, TIMSFFI_OK); + unsafe { + tims_config_free(cfg); + tims_close(handle); + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test --test ffi_lifecycle -- --nocapture` +Expected: All 17 tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add tests/ffi_lifecycle.rs +git commit -m "test: add config builder and open_with_config lifecycle tests" +``` + +## Chunk 2: Error Handling & Spectrum Tests + +### Task 4: Error handling tests + +**Files:** +- Create: `tests/ffi_error_handling.rs` + +- [ ] **Step 1: Write the error handling test file** + +```rust +// tests/ffi_error_handling.rs +mod common; + +use common::*; +use std::ffi::CString; +use std::ptr; + +#[test] +fn get_last_error_null_handle_reads_global() { + // Trigger a global error by opening a nonexistent path + let path = CString::new("/tmp/timsrust_ffi_test_no_such_path").unwrap(); + let mut handle: *mut tims_dataset = ptr::null_mut(); + let _ = unsafe { tims_open(path.as_ptr(), &mut handle) }; + + // Read global error (null handle) + let mut buf = [0i8; 256]; + let status = unsafe { tims_get_last_error(ptr::null_mut(), buf.as_mut_ptr(), 256) }; + assert_status(status, TIMSFFI_OK); + let msg = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) } + .to_str() + .unwrap(); + assert!(!msg.is_empty(), "global error should be non-empty after failed open"); +} + +#[test] +fn get_last_error_with_valid_handle_reads_per_handle() { + let handle = open_stub(); + // Trigger a per-handle error: get_frame on stub (0 frames -> OOB) + let mut frame = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_frame(handle, 0, frame.as_mut_ptr()) }; + assert_status(status, TIMSFFI_ERR_INDEX_OOB); + + // Read per-handle error + let mut buf = [0i8; 256]; + let status = unsafe { tims_get_last_error(handle, buf.as_mut_ptr(), 256) }; + assert_status(status, TIMSFFI_OK); + let msg = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) } + .to_str() + .unwrap(); + assert!(!msg.is_empty(), "per-handle error should be set after OOB frame access"); + + unsafe { tims_close(handle); } +} + +#[test] +fn get_last_error_null_buffer_returns_internal() { + let status = unsafe { tims_get_last_error(ptr::null_mut(), ptr::null_mut(), 256) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +#[test] +fn get_last_error_zero_length_buffer_returns_internal() { + let mut buf = [0i8; 1]; + let status = unsafe { tims_get_last_error(ptr::null_mut(), buf.as_mut_ptr(), 0) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +#[test] +fn get_last_error_truncation() { + // Trigger a global error + let path = CString::new("/tmp/timsrust_ffi_no_such_thing").unwrap(); + let mut handle: *mut tims_dataset = ptr::null_mut(); + let _ = unsafe { tims_open(path.as_ptr(), &mut handle) }; + + // Read into a tiny buffer (5 bytes = 4 chars + null) + let mut buf = [0i8; 5]; + let status = unsafe { tims_get_last_error(ptr::null_mut(), buf.as_mut_ptr(), 5) }; + assert_status(status, TIMSFFI_OK); + // Must be null-terminated + let msg = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) } + .to_str() + .unwrap(); + assert_eq!(msg.len(), 4, "should truncate to buf_len - 1 = 4 chars"); + assert_eq!(buf[4], 0, "must be null-terminated"); +} + +#[test] +fn get_last_error_after_success_is_empty() { + // Successful open should clear global error + let handle = open_stub(); + let mut buf = [0i8; 256]; + let status = unsafe { tims_get_last_error(ptr::null_mut(), buf.as_mut_ptr(), 256) }; + assert_status(status, TIMSFFI_OK); + let msg = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) } + .to_str() + .unwrap(); + assert!(msg.is_empty(), "no error after successful open"); + unsafe { tims_close(handle); } +} + +#[test] +fn error_message_content_meaningful() { + let path = CString::new("/tmp/timsrust_ffi_definitely_not_a_real_path").unwrap(); + let mut handle: *mut tims_dataset = ptr::null_mut(); + let _ = unsafe { tims_open(path.as_ptr(), &mut handle) }; + + let mut buf = [0i8; 512]; + let status = unsafe { tims_get_last_error(ptr::null_mut(), buf.as_mut_ptr(), 512) }; + assert_status(status, TIMSFFI_OK); + let msg = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) } + .to_str() + .unwrap(); + assert!( + msg.contains("path") || msg.contains("not found") || msg.contains("error"), + "error message should contain useful context, got: {msg}" + ); +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test --test ffi_error_handling -- --nocapture` +Expected: All 7 tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add tests/ffi_error_handling.rs +git commit -m "test: add FFI error handling tests" +``` + +### Task 5: Spectrum tests (single + batch, stub mode) + +**Files:** +- Create: `tests/ffi_spectrum.rs` + +- [ ] **Step 1: Write the spectrum test file** + +```rust +// tests/ffi_spectrum.rs +mod common; + +use common::*; +use std::ptr; + +// ---- Single spectrum tests ---- + +#[test] +fn get_spectrum_index_0_stub_returns_oob() { + let handle = open_stub(); + let mut spec = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_spectrum(handle, 0, spec.as_mut_ptr()) }; + assert_status(status, TIMSFFI_ERR_INDEX_OOB); + unsafe { tims_close(handle); } +} + +#[test] +fn get_spectrum_null_handle_returns_internal() { + let mut spec = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_spectrum(ptr::null_mut(), 0, spec.as_mut_ptr()) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +#[test] +fn get_spectrum_null_out_returns_internal() { + let handle = open_stub(); + let status = unsafe { tims_get_spectrum(handle, 0, ptr::null_mut()) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); + unsafe { tims_close(handle); } +} + +// ---- Batch spectrum tests ---- + +#[test] +fn get_spectra_by_rt_stub_returns_empty() { + let handle = open_stub(); + let mut count: u32 = 99; + let mut specs: *mut TimsFfiSpectrum = ptr::null_mut(); + let status = unsafe { + tims_get_spectra_by_rt(handle, 100.0, 5, 0.0, 2.0, &mut count, &mut specs) + }; + assert_status(status, TIMSFFI_OK); + assert_eq!(count, 0); + assert!(specs.is_null()); + unsafe { tims_close(handle); } +} + +#[test] +fn get_spectra_by_rt_null_handle_returns_internal() { + let mut count: u32 = 0; + let mut specs: *mut TimsFfiSpectrum = ptr::null_mut(); + let status = unsafe { + tims_get_spectra_by_rt(ptr::null_mut(), 100.0, 5, 0.0, 2.0, &mut count, &mut specs) + }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +#[test] +fn get_spectra_by_rt_n_zero_returns_empty() { + let handle = open_stub(); + let mut count: u32 = 99; + let mut specs: *mut TimsFfiSpectrum = ptr::null_mut(); + let status = unsafe { + tims_get_spectra_by_rt(handle, 100.0, 0, 0.0, 2.0, &mut count, &mut specs) + }; + assert_status(status, TIMSFFI_OK); + assert_eq!(count, 0); + unsafe { tims_close(handle); } +} + +#[test] +fn get_spectra_by_rt_negative_n_returns_empty() { + let handle = open_stub(); + let mut count: u32 = 99; + let mut specs: *mut TimsFfiSpectrum = ptr::null_mut(); + let status = unsafe { + tims_get_spectra_by_rt(handle, 100.0, -5, 0.0, 2.0, &mut count, &mut specs) + }; + assert_status(status, TIMSFFI_OK); + assert_eq!(count, 0); + unsafe { tims_close(handle); } +} + +#[test] +fn free_spectrum_array_null_no_crash() { + let handle = open_stub(); + unsafe { tims_free_spectrum_array(handle, ptr::null_mut(), 0); } + unsafe { tims_close(handle); } +} + +#[test] +fn free_spectrum_array_non_null_count_zero() { + // Allocate a small buffer, pass count=0 — should free the array + // without iterating per-element. + let handle = open_stub(); + let ptr = unsafe { libc::malloc(std::mem::size_of::()) } as *mut TimsFfiSpectrum; + assert!(!ptr.is_null()); + unsafe { tims_free_spectrum_array(handle, ptr, 0); } + // If we get here without crash, test passes. + unsafe { tims_close(handle); } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test --test ffi_spectrum -- --nocapture` +Expected: All 9 tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add tests/ffi_spectrum.rs +git commit -m "test: add single and batch spectrum FFI tests (stub mode)" +``` + +### Task 6: Frame tests (single + batch, stub mode) + +**Files:** +- Create: `tests/ffi_frame.rs` + +- [ ] **Step 1: Write the frame test file** + +```rust +// tests/ffi_frame.rs +mod common; + +use common::*; +use std::ptr; + +// ---- Single frame tests ---- + +#[test] +fn get_frame_index_0_stub_returns_oob() { + let handle = open_stub(); + let mut frame = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_frame(handle, 0, frame.as_mut_ptr()) }; + assert_status(status, TIMSFFI_ERR_INDEX_OOB); + unsafe { tims_close(handle); } +} + +#[test] +fn get_frame_null_handle_returns_internal() { + let mut frame = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_frame(ptr::null_mut(), 0, frame.as_mut_ptr()) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +#[test] +fn get_frame_null_out_returns_internal() { + let handle = open_stub(); + let status = unsafe { tims_get_frame(handle, 0, ptr::null_mut()) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); + unsafe { tims_close(handle); } +} + +// ---- Batch frame tests ---- + +#[test] +fn get_frames_by_level_stub_returns_empty() { + let handle = open_stub(); + let mut count: u32 = 99; + let mut frames: *mut TimsFfiFrame = ptr::null_mut(); + let status = unsafe { tims_get_frames_by_level(handle, 1, &mut count, &mut frames) }; + assert_status(status, TIMSFFI_OK); + assert_eq!(count, 0); + assert!(frames.is_null()); + unsafe { tims_close(handle); } +} + +#[test] +fn get_frames_by_level_null_handle_returns_internal() { + let mut count: u32 = 0; + let mut frames: *mut TimsFfiFrame = ptr::null_mut(); + let status = unsafe { tims_get_frames_by_level(ptr::null_mut(), 1, &mut count, &mut frames) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +#[test] +fn free_frame_array_null_no_crash() { + let handle = open_stub(); + unsafe { tims_free_frame_array(handle, ptr::null_mut(), 0); } + unsafe { tims_close(handle); } +} + +#[test] +fn free_frame_array_non_null_count_zero() { + let handle = open_stub(); + let ptr = unsafe { libc::malloc(std::mem::size_of::()) } as *mut TimsFfiFrame; + assert!(!ptr.is_null()); + unsafe { tims_free_frame_array(handle, ptr, 0); } + unsafe { tims_close(handle); } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test --test ffi_frame -- --nocapture` +Expected: All 7 tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add tests/ffi_frame.rs +git commit -m "test: add single and batch frame FFI tests (stub mode)" +``` + +## Chunk 3: Query, Converter & Real Data Tests + +### Task 7: Query and metadata tests + +**Files:** +- Create: `tests/ffi_query.rs` + +- [ ] **Step 1: Write the query test file** + +```rust +// tests/ffi_query.rs +mod common; + +use common::*; +use std::ptr; + +#[test] +fn num_spectra_stub_returns_zero() { + let handle = open_stub(); + let n = unsafe { tims_num_spectra(handle as *const _) }; + assert_eq!(n, 0); + unsafe { tims_close(handle); } +} + +#[test] +fn num_spectra_null_handle_returns_zero() { + let n = unsafe { tims_num_spectra(ptr::null()) }; + assert_eq!(n, 0); +} + +#[test] +fn num_frames_stub_returns_zero() { + let handle = open_stub(); + let n = unsafe { tims_num_frames(handle as *const _) }; + assert_eq!(n, 0); + unsafe { tims_close(handle); } +} + +#[test] +fn num_frames_null_handle_returns_zero() { + let n = unsafe { tims_num_frames(ptr::null()) }; + assert_eq!(n, 0); +} + +#[test] +fn get_swath_windows_stub_returns_empty() { + let handle = open_stub(); + let mut count: u32 = 99; + let mut windows: *mut TimsFfiSwathWindow = ptr::null_mut(); + let status = unsafe { tims_get_swath_windows(handle, &mut count, &mut windows) }; + assert_status(status, TIMSFFI_OK); + assert_eq!(count, 0); + unsafe { tims_close(handle); } +} + +#[test] +fn get_swath_windows_null_handle_returns_internal() { + let mut count: u32 = 0; + let mut windows: *mut TimsFfiSwathWindow = ptr::null_mut(); + let status = unsafe { tims_get_swath_windows(ptr::null_mut(), &mut count, &mut windows) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +#[test] +fn free_swath_windows_null_no_crash() { + unsafe { tims_free_swath_windows(ptr::null_mut(), ptr::null_mut()); } +} + +#[test] +fn file_info_stub_returns_zeros() { + let handle = open_stub(); + let mut info = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_file_info(handle, info.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + let info = unsafe { info.assume_init() }; + assert_eq!(info.num_frames, 0); + assert_eq!(info.num_spectra_ms2, 0); + assert_eq!(info.total_peaks, 0); + assert_eq!(info.ms1.count, 0); + assert_eq!(info.ms2.count, 0); + // After finalize(), min/max fields should be 0.0 (not infinity) + assert_eq!(info.ms1.rt_min, 0.0); + assert_eq!(info.ms1.rt_max, 0.0); + assert_eq!(info.ms2.mz_min, 0.0); + assert_eq!(info.ms2.mz_max, 0.0); + unsafe { tims_close(handle); } +} + +#[test] +fn file_info_null_handle_returns_internal() { + let mut info = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_file_info(ptr::null_mut(), info.as_mut_ptr()) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +#[test] +fn file_info_null_out_returns_internal() { + let handle = open_stub(); + let status = unsafe { tims_file_info(handle, ptr::null_mut()) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); + unsafe { tims_close(handle); } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test --test ffi_query -- --nocapture` +Expected: All 10 tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add tests/ffi_query.rs +git commit -m "test: add query and metadata FFI tests (stub mode)" +``` + +### Task 8: Converter tests + +**Files:** +- Create: `tests/ffi_converters.rs` + +- [ ] **Step 1: Write the converter test file** + +```rust +// tests/ffi_converters.rs +mod common; + +use common::*; +use std::ptr; + +// ---- Scalar converters ---- + +#[test] +fn tof_to_mz_null_handle_returns_nan() { + let result = unsafe { tims_convert_tof_to_mz(ptr::null(), 100) }; + assert!(result.is_nan()); +} + +#[test] +fn scan_to_im_null_handle_returns_nan() { + let result = unsafe { tims_convert_scan_to_im(ptr::null(), 100) }; + assert!(result.is_nan()); +} + +// Stub-specific: identity converter only applies without with_timsrust. +// With the real timsrust feature, converters use calibration data. +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn tof_to_mz_stub_returns_identity() { + let handle = open_stub(); + // Stub converter returns the input value unchanged + let result = unsafe { tims_convert_tof_to_mz(handle as *const _, 42) }; + assert_eq!(result, 42.0); + unsafe { tims_close(handle); } +} + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn scan_to_im_stub_returns_identity() { + let handle = open_stub(); + let result = unsafe { tims_convert_scan_to_im(handle as *const _, 99) }; + assert_eq!(result, 99.0); + unsafe { tims_close(handle); } +} + +// ---- Array converters: null checks ---- + +#[test] +fn tof_to_mz_array_null_handle_returns_internal() { + let input: [u32; 1] = [100]; + let mut output: [f64; 1] = [0.0]; + let status = unsafe { + tims_convert_tof_to_mz_array(ptr::null(), input.as_ptr(), 1, output.as_mut_ptr()) + }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +#[test] +fn tof_to_mz_array_null_input_returns_internal() { + let handle = open_stub(); + let mut output: [f64; 1] = [0.0]; + let status = unsafe { + tims_convert_tof_to_mz_array(handle as *const _, ptr::null(), 1, output.as_mut_ptr()) + }; + assert_status(status, TIMSFFI_ERR_INTERNAL); + unsafe { tims_close(handle); } +} + +#[test] +fn tof_to_mz_array_null_output_returns_internal() { + let handle = open_stub(); + let input: [u32; 1] = [100]; + let status = unsafe { + tims_convert_tof_to_mz_array(handle as *const _, input.as_ptr(), 1, ptr::null_mut()) + }; + assert_status(status, TIMSFFI_ERR_INTERNAL); + unsafe { tims_close(handle); } +} + +#[test] +fn tof_to_mz_array_count_zero_returns_ok() { + let handle = open_stub(); + // Non-null pointers but count=0: should return OK without dereferencing + let input: [u32; 1] = [100]; + let mut output: [f64; 1] = [0.0]; + let status = unsafe { + tims_convert_tof_to_mz_array(handle as *const _, input.as_ptr(), 0, output.as_mut_ptr()) + }; + assert_status(status, TIMSFFI_OK); + // output should be untouched + assert_eq!(output[0], 0.0); + unsafe { tims_close(handle); } +} + +#[test] +fn tof_to_mz_array_stub_matches_scalar() { + let handle = open_stub(); + let input: [u32; 3] = [10, 200, 5000]; + let mut output: [f64; 3] = [0.0; 3]; + let status = unsafe { + tims_convert_tof_to_mz_array(handle as *const _, input.as_ptr(), 3, output.as_mut_ptr()) + }; + assert_status(status, TIMSFFI_OK); + for i in 0..3 { + let scalar = unsafe { tims_convert_tof_to_mz(handle as *const _, input[i]) }; + assert_eq!(output[i], scalar, "array[{i}] should match scalar"); + } + unsafe { tims_close(handle); } +} + +// ---- scan_to_im_array: mirror of tof tests ---- + +#[test] +fn scan_to_im_array_null_handle_returns_internal() { + let input: [u32; 1] = [100]; + let mut output: [f64; 1] = [0.0]; + let status = unsafe { + tims_convert_scan_to_im_array(ptr::null(), input.as_ptr(), 1, output.as_mut_ptr()) + }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +#[test] +fn scan_to_im_array_null_input_returns_internal() { + let handle = open_stub(); + let mut output: [f64; 1] = [0.0]; + let status = unsafe { + tims_convert_scan_to_im_array(handle as *const _, ptr::null(), 1, output.as_mut_ptr()) + }; + assert_status(status, TIMSFFI_ERR_INTERNAL); + unsafe { tims_close(handle); } +} + +#[test] +fn scan_to_im_array_null_output_returns_internal() { + let handle = open_stub(); + let input: [u32; 1] = [100]; + let status = unsafe { + tims_convert_scan_to_im_array(handle as *const _, input.as_ptr(), 1, ptr::null_mut()) + }; + assert_status(status, TIMSFFI_ERR_INTERNAL); + unsafe { tims_close(handle); } +} + +#[test] +fn scan_to_im_array_count_zero_returns_ok() { + let handle = open_stub(); + let input: [u32; 1] = [100]; + let mut output: [f64; 1] = [0.0]; + let status = unsafe { + tims_convert_scan_to_im_array(handle as *const _, input.as_ptr(), 0, output.as_mut_ptr()) + }; + assert_status(status, TIMSFFI_OK); + assert_eq!(output[0], 0.0); + unsafe { tims_close(handle); } +} + +#[test] +fn scan_to_im_array_stub_matches_scalar() { + let handle = open_stub(); + let input: [u32; 3] = [10, 200, 5000]; + let mut output: [f64; 3] = [0.0; 3]; + let status = unsafe { + tims_convert_scan_to_im_array(handle as *const _, input.as_ptr(), 3, output.as_mut_ptr()) + }; + assert_status(status, TIMSFFI_OK); + for i in 0..3 { + let scalar = unsafe { tims_convert_scan_to_im(handle as *const _, input[i]) }; + assert_eq!(output[i], scalar, "array[{i}] should match scalar"); + } + unsafe { tims_close(handle); } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test --test ffi_converters -- --nocapture` +Expected: All 14 tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add tests/ffi_converters.rs +git commit -m "test: add converter FFI tests (stub mode)" +``` + +### Task 9: Real data tests + +**Files:** +- Create: `tests/ffi_real_data.rs` + +- [ ] **Step 1: Write the real data test file** + +```rust +// tests/ffi_real_data.rs +// +// Integration tests that run against real .d datasets. +// Skipped if TIMSRUST_TEST_DATA_DDA / TIMSRUST_TEST_DATA_DIA env vars are unset. +// Requires: cargo test --features with_timsrust +mod common; + +use common::*; +use std::ptr; + +// ---- Helpers ---- + +macro_rules! require_dda { + () => { + match dda_path() { + Some(p) => p, + None => { + eprintln!("TIMSRUST_TEST_DATA_DDA not set, skipping"); + return; + } + } + }; +} + +macro_rules! require_dia { + () => { + match dia_path() { + Some(p) => p, + None => { + eprintln!("TIMSRUST_TEST_DATA_DIA not set, skipping"); + return; + } + } + }; +} + +// ====================================================================== +// DDA Tests +// ====================================================================== + +#[test] +fn dda_open_succeeds() { + let path = require_dda!(); + let handle = open_real(&path); + unsafe { tims_close(handle); } +} + +#[test] +fn dda_num_spectra_positive() { + let path = require_dda!(); + let handle = open_real(&path); + let n = unsafe { tims_num_spectra(handle as *const _) }; + assert!(n > 0, "DDA dataset should have spectra, got {n}"); + unsafe { tims_close(handle); } +} + +#[test] +fn dda_num_frames_positive() { + let path = require_dda!(); + let handle = open_real(&path); + let nf = unsafe { tims_num_frames(handle as *const _) }; + let ns = unsafe { tims_num_spectra(handle as *const _) }; + assert!(nf > 0, "DDA dataset should have frames"); + assert!(nf <= ns, "expected num_frames({nf}) <= num_spectra({ns}) for test dataset"); + unsafe { tims_close(handle); } +} + +#[test] +fn dda_get_spectrum_0() { + let path = require_dda!(); + let handle = open_real(&path); + let mut spec = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_spectrum(handle, 0, spec.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + let spec = unsafe { spec.assume_init() }; + assert!(spec.num_peaks > 0, "first spectrum should have peaks"); + assert!(!spec.mz.is_null()); + assert!(!spec.intensity.is_null()); + assert!(spec.ms_level == 1 || spec.ms_level == 2); + unsafe { tims_close(handle); } +} + +#[test] +fn dda_spectrum_mz_sorted() { + let path = require_dda!(); + let handle = open_real(&path); + let mut spec = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_spectrum(handle, 0, spec.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + let spec = unsafe { spec.assume_init() }; + let n = spec.num_peaks as usize; + if n > 1 { + let mz = unsafe { std::slice::from_raw_parts(spec.mz, n) }; + for i in 1..n { + assert!(mz[i] >= mz[i - 1], "mz not sorted at index {i}: {} < {}", mz[i], mz[i - 1]); + } + } + unsafe { tims_close(handle); } +} + +#[test] +fn dda_spectrum_intensity_non_negative() { + let path = require_dda!(); + let handle = open_real(&path); + let mut spec = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_spectrum(handle, 0, spec.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + let spec = unsafe { spec.assume_init() }; + let n = spec.num_peaks as usize; + if n > 0 { + let inten = unsafe { std::slice::from_raw_parts(spec.intensity, n) }; + for (i, &val) in inten.iter().enumerate() { + assert!(val >= 0.0, "negative intensity at {i}: {val}"); + } + } + unsafe { tims_close(handle); } +} + +#[test] +fn dda_spectrum_metadata_fields() { + let path = require_dda!(); + let handle = open_real(&path); + // Find an MS2 spectrum to check metadata + let n = unsafe { tims_num_spectra(handle as *const _) }; + let mut found_ms2 = false; + for i in 0..n.min(100) { + let mut spec = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_spectrum(handle, i, spec.as_mut_ptr()) }; + if status != TIMSFFI_OK { continue; } + let spec = unsafe { spec.assume_init() }; + if spec.ms_level == 2 { + assert!(spec.frame_index != u32::MAX, "MS2 should have frame_index"); + assert!(spec.isolation_width >= 0.0); + assert!(spec.isolation_mz >= 0.0); + found_ms2 = true; + break; + } + } + if !found_ms2 { + eprintln!("no MS2 spectrum found in first 100 spectra, skipping metadata check"); + } + unsafe { tims_close(handle); } +} + +#[test] +fn dda_get_frame_0() { + let path = require_dda!(); + let handle = open_real(&path); + let mut frame = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_frame(handle, 0, frame.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + let frame = unsafe { frame.assume_init() }; + assert!(frame.num_peaks > 0, "first frame should have peaks"); + assert!(frame.num_scans > 0, "first frame should have scans"); + unsafe { tims_close(handle); } +} + +#[test] +fn dda_frame_scan_offsets_monotonic() { + let path = require_dda!(); + let handle = open_real(&path); + let mut frame = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_frame(handle, 0, frame.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + let frame = unsafe { frame.assume_init() }; + if frame.num_scans > 0 && !frame.scan_offsets.is_null() { + let offsets = unsafe { + std::slice::from_raw_parts(frame.scan_offsets, frame.num_scans as usize + 1) + }; + for i in 1..offsets.len() { + assert!(offsets[i] >= offsets[i - 1], "scan_offsets not monotonic at {i}"); + } + assert_eq!( + *offsets.last().unwrap(), + frame.num_peaks as u64, + "last scan_offset should equal num_peaks" + ); + } + unsafe { tims_close(handle); } +} + +#[test] +fn dda_get_frames_by_level_ms1() { + let path = require_dda!(); + let handle = open_real(&path); + let mut count: u32 = 0; + let mut frames: *mut TimsFfiFrame = ptr::null_mut(); + let status = unsafe { tims_get_frames_by_level(handle, 1, &mut count, &mut frames) }; + assert_status(status, TIMSFFI_OK); + assert!(count > 0, "DDA should have MS1 frames"); + // Spot check: first frame should be MS1 + if count > 0 && !frames.is_null() { + let first = unsafe { *frames }; + assert_eq!(first.ms_level, 1, "frames_by_level(1) should return MS1 frames"); + } + unsafe { tims_free_frame_array(handle, frames, count); } + unsafe { tims_close(handle); } +} + +#[test] +fn dda_get_spectra_by_rt() { + let path = require_dda!(); + let handle = open_real(&path); + // Get file info to find a reasonable RT + let mut info = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_file_info(handle, info.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + let info = unsafe { info.assume_init() }; + let mid_rt = (info.ms2.rt_min + info.ms2.rt_max) / 2.0; + + let mut count: u32 = 0; + let mut specs: *mut TimsFfiSpectrum = ptr::null_mut(); + let status = unsafe { + tims_get_spectra_by_rt(handle, mid_rt, 3, 0.0, 1e15, &mut count, &mut specs) + }; + assert_status(status, TIMSFFI_OK); + assert!(count > 0, "should find spectra near mid RT={mid_rt}"); + if count > 0 && !specs.is_null() { + let first = unsafe { *specs }; + // RT should be somewhat near what we requested + assert!( + (first.rt_seconds - mid_rt).abs() < 60.0, + "returned RT {} too far from requested {mid_rt}", + first.rt_seconds + ); + } + unsafe { tims_free_spectrum_array(handle, specs, count); } + unsafe { tims_close(handle); } +} + +#[test] +fn dda_converters_positive_finite() { + let path = require_dda!(); + let handle = open_real(&path); + let mz = unsafe { tims_convert_tof_to_mz(handle as *const _, 100) }; + assert!(mz.is_finite() && mz > 0.0, "tof_to_mz should be positive finite, got {mz}"); + let im = unsafe { tims_convert_scan_to_im(handle as *const _, 100) }; + assert!(im.is_finite() && im > 0.0, "scan_to_im should be positive finite, got {im}"); + unsafe { tims_close(handle); } +} + +#[test] +fn dda_converter_array_matches_scalar() { + let path = require_dda!(); + let handle = open_real(&path); + let indices: [u32; 3] = [50, 100, 500]; + let mut out_mz: [f64; 3] = [0.0; 3]; + let status = unsafe { + tims_convert_tof_to_mz_array(handle as *const _, indices.as_ptr(), 3, out_mz.as_mut_ptr()) + }; + assert_status(status, TIMSFFI_OK); + for i in 0..3 { + let scalar = unsafe { tims_convert_tof_to_mz(handle as *const _, indices[i]) }; + assert!( + (out_mz[i] - scalar).abs() < 1e-10, + "array[{i}]={} != scalar={scalar}", + out_mz[i] + ); + } + unsafe { tims_close(handle); } +} + +#[test] +fn dda_file_info_populated() { + let path = require_dda!(); + let handle = open_real(&path); + let mut info = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_file_info(handle, info.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + let info = unsafe { info.assume_init() }; + assert!(info.total_peaks > 0); + assert!(info.ms2.rt_min < info.ms2.rt_max, "RT range should be valid"); + assert!(info.ms2.mz_min > 0.0, "mz min should be positive"); + unsafe { tims_close(handle); } +} + +#[test] +fn dda_swath_windows() { + let path = require_dda!(); + let handle = open_real(&path); + let mut count: u32 = 0; + let mut windows: *mut TimsFfiSwathWindow = ptr::null_mut(); + let status = unsafe { tims_get_swath_windows(handle, &mut count, &mut windows) }; + assert_status(status, TIMSFFI_OK); + // DDA may have 0 swath windows — that's fine + if count > 0 { + unsafe { tims_free_swath_windows(handle, windows); } + } + unsafe { tims_close(handle); } +} + +// ====================================================================== +// DIA Tests +// ====================================================================== + +#[test] +fn dia_open_succeeds() { + let path = require_dia!(); + let handle = open_real(&path); + unsafe { tims_close(handle); } +} + +#[test] +fn dia_spectra_much_more_than_frames() { + let path = require_dia!(); + let handle = open_real(&path); + let ns = unsafe { tims_num_spectra(handle as *const _) }; + let nf = unsafe { tims_num_frames(handle as *const _) }; + assert!( + ns > nf * 2, + "DIA-PASEF: expected num_spectra({ns}) >> num_frames({nf})" + ); + unsafe { tims_close(handle); } +} + +#[test] +fn dia_get_spectra_by_rt_with_im_filter() { + let path = require_dia!(); + let handle = open_real(&path); + let mut info = std::mem::MaybeUninit::::zeroed(); + let _ = unsafe { tims_file_info(handle, info.as_mut_ptr()) }; + let info = unsafe { info.assume_init() }; + let mid_rt = (info.ms2.rt_min + info.ms2.rt_max) / 2.0; + let mid_im = (info.ms2.im_min + info.ms2.im_max) / 2.0; + let im_range = 0.05; // narrow IM window + + let mut count: u32 = 0; + let mut specs: *mut TimsFfiSpectrum = ptr::null_mut(); + let status = unsafe { + tims_get_spectra_by_rt( + handle, + mid_rt, + 10, + mid_im - im_range, + mid_im + im_range, + &mut count, + &mut specs, + ) + }; + assert_status(status, TIMSFFI_OK); + if count > 0 && !specs.is_null() { + for i in 0..count as usize { + let spec = unsafe { *specs.add(i) }; + assert!( + spec.im >= mid_im - im_range - 0.01 && spec.im <= mid_im + im_range + 0.01, + "spectrum {i} IM {} outside filter [{}, {}]", + spec.im, + mid_im - im_range, + mid_im + im_range + ); + } + } + unsafe { tims_free_spectrum_array(handle, specs, count); } + unsafe { tims_close(handle); } +} + +#[test] +fn dia_swath_windows_populated() { + let path = require_dia!(); + let handle = open_real(&path); + let mut count: u32 = 0; + let mut windows: *mut TimsFfiSwathWindow = ptr::null_mut(); + let status = unsafe { tims_get_swath_windows(handle, &mut count, &mut windows) }; + assert_status(status, TIMSFFI_OK); + assert!(count > 0, "DIA dataset should have swath windows"); + if count > 0 && !windows.is_null() { + let wins = unsafe { std::slice::from_raw_parts(windows, count as usize) }; + for (i, w) in wins.iter().enumerate() { + assert!(w.mz_lower < w.mz_upper, "window {i}: mz_lower >= mz_upper"); + assert!(w.im_lower < w.im_upper, "window {i}: im_lower >= im_upper"); + } + // Check coverage: windows should span a reasonable m/z range + let min_mz = wins.iter().map(|w| w.mz_lower).fold(f64::INFINITY, f64::min); + let max_mz = wins.iter().map(|w| w.mz_upper).fold(f64::NEG_INFINITY, f64::max); + assert!(max_mz - min_mz > 100.0, "swath windows should cover > 100 m/z"); + } + unsafe { tims_free_swath_windows(handle, windows); } + unsafe { tims_close(handle); } +} + +// ====================================================================== +// Cross-cutting +// ====================================================================== + +#[test] +fn reopen_after_close() { + let path = require_dda!(); + let handle1 = open_real(&path); + let n1 = unsafe { tims_num_spectra(handle1 as *const _) }; + unsafe { tims_close(handle1); } + + let handle2 = open_real(&path); + let n2 = unsafe { tims_num_spectra(handle2 as *const _) }; + assert_eq!(n1, n2, "reopened dataset should have same spectrum count"); + unsafe { tims_close(handle2); } +} + +#[test] +fn two_handles_simultaneously() { + let dda = match dda_path() { Some(p) => p, None => return }; + let dia = match dia_path() { Some(p) => p, None => return }; + + let h_dda = open_real(&dda); + let h_dia = open_real(&dia); + + let n_dda = unsafe { tims_num_spectra(h_dda as *const _) }; + let n_dia = unsafe { tims_num_spectra(h_dia as *const _) }; + assert!(n_dda > 0); + assert!(n_dia > 0); + // They should be different datasets with different counts + // (not a hard requirement, but expected) + + unsafe { + tims_close(h_dda); + tims_close(h_dia); + } +} +``` + +- [ ] **Step 2: Run tests (stub mode — all should skip gracefully)** + +Run: `cargo test --test ffi_real_data -- --nocapture` +Expected: All tests print "...not set, skipping" and pass (0 failures) + +- [ ] **Step 3: Commit** + +```bash +git add tests/ffi_real_data.rs +git commit -m "test: add real-data integration tests (DDA + DIA, env-gated)" +``` + +## Chunk 4: C++ Test Suite + +### Task 10: Catch2 setup and ABI tests + +**Files:** +- Create: `tests_cpp/catch2/catch.hpp` (download Catch2 v3 single header) +- Create: `tests_cpp/test_abi.cpp` + +- [ ] **Step 1: Download Catch2 single header** + +```bash +mkdir -p tests_cpp/catch2 +curl -L -o tests_cpp/catch2/catch.hpp \ + "https://github.com/catchorg/Catch2/releases/download/v3.5.2/catch_amalgamated.hpp" +curl -L -o tests_cpp/catch2/catch.cpp \ + "https://github.com/catchorg/Catch2/releases/download/v3.5.2/catch_amalgamated.cpp" +``` + +- [ ] **Step 2: Write the ABI test file** + +```cpp +// tests_cpp/test_abi.cpp +// +// Compile-time and runtime ABI layout checks. +// Validates that the C header struct definitions match the Rust #[repr(C)] layout. + +#include "../include/timsrust_cpp_bridge.h" +#include "catch2/catch.hpp" + +#include +#include + +// ---- Enum value checks ---- + +TEST_CASE("Status enum values", "[abi]") { + REQUIRE(TIMSFFI_OK == 0); + REQUIRE(TIMSFFI_ERR_INVALID_UTF8 == 1); + REQUIRE(TIMSFFI_ERR_OPEN_FAILED == 2); + REQUIRE(TIMSFFI_ERR_INDEX_OOB == 3); + REQUIRE(TIMSFFI_ERR_INTERNAL == 255); +} + +// ---- Struct size checks ---- +// These sizes are platform-specific (x86_64 Linux with standard alignment). +// If building on a different platform, update expected sizes or derive them +// from a Rust helper: std::mem::size_of::() + +TEST_CASE("tims_spectrum sizeof", "[abi]") { + // Expected: 8+8+1+3pad+4+8+8+8+4+8+8+1+7pad+8+4+4pad = platform dependent + // We check it matches what Rust reports. For now, just verify it compiles + // and is > 0. + REQUIRE(sizeof(tims_spectrum) > 0); +} + +TEST_CASE("tims_frame sizeof", "[abi]") { + REQUIRE(sizeof(tims_frame) > 0); +} + +TEST_CASE("tims_swath_window sizeof", "[abi]") { + REQUIRE(sizeof(tims_swath_window) > 0); +} + +TEST_CASE("tims_level_stats sizeof", "[abi]") { + REQUIRE(sizeof(tims_level_stats) > 0); +} + +TEST_CASE("tims_file_info_t sizeof", "[abi]") { + REQUIRE(sizeof(tims_file_info_t) > 0); +} + +// ---- Key field offset checks ---- + +TEST_CASE("tims_spectrum field offsets", "[abi]") { + // Verify critical pointer fields are at expected positions + REQUIRE(offsetof(tims_spectrum, rt_seconds) == 0); + REQUIRE(offsetof(tims_spectrum, mz) > offsetof(tims_spectrum, num_peaks)); + REQUIRE(offsetof(tims_spectrum, intensity) > offsetof(tims_spectrum, mz)); + REQUIRE(offsetof(tims_spectrum, im) > offsetof(tims_spectrum, intensity)); +} + +TEST_CASE("tims_frame field offsets", "[abi]") { + REQUIRE(offsetof(tims_frame, index) == 0); + REQUIRE(offsetof(tims_frame, tof_indices) > offsetof(tims_frame, num_peaks)); + REQUIRE(offsetof(tims_frame, intensities) > offsetof(tims_frame, tof_indices)); + REQUIRE(offsetof(tims_frame, scan_offsets) > offsetof(tims_frame, intensities)); +} + +TEST_CASE("tims_swath_window field offsets", "[abi]") { + REQUIRE(offsetof(tims_swath_window, mz_lower) == 0); + REQUIRE(offsetof(tims_swath_window, mz_upper) == sizeof(double)); + REQUIRE(offsetof(tims_swath_window, is_ms1) > offsetof(tims_swath_window, im_upper)); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add tests_cpp/catch2/ tests_cpp/test_abi.cpp +git commit -m "test: add Catch2 vendored header and ABI layout tests" +``` + +### Task 11: C++ smoke tests and config effects + +**Files:** +- Create: `tests_cpp/test_smoke.cpp` + +- [ ] **Step 1: Write the smoke test file** + +```cpp +// tests_cpp/test_smoke.cpp +// +// End-to-end smoke tests and config effect tests. +// Tests requiring real data are gated behind DDA/DIA env vars. + +#include "../include/timsrust_cpp_bridge.h" +#include "catch2/catch.hpp" + +#include +#include +#include +#include +#include + +static std::string get_env(const char* name) { + const char* val = std::getenv(name); + return val ? std::string(val) : std::string(); +} + +// ---- Error path (no dataset needed) ---- + +TEST_CASE("Open bad path returns error", "[smoke]") { + tims_dataset* handle = nullptr; + auto status = tims_open("/tmp/timsrust_cpp_test_nonexistent", &handle); + REQUIRE(status != TIMSFFI_OK); + + char buf[256] = {}; + tims_get_last_error(nullptr, buf, sizeof(buf)); + REQUIRE(std::strlen(buf) > 0); +} + +// ---- Config builder round-trip (no dataset needed for stub) ---- + +TEST_CASE("Config builder round-trip", "[smoke]") { + auto* cfg = tims_config_create(); + REQUIRE(cfg != nullptr); + tims_config_set_smoothing_window(cfg, 3); + tims_config_set_centroiding_window(cfg, 5); + tims_config_set_calibration_tolerance(cfg, 0.01); + tims_config_set_calibrate(cfg, 1); + tims_config_free(cfg); +} + +// ---- DDA smoke (real data) ---- + +TEST_CASE("DDA smoke test", "[smoke][dda]") { + auto dda = get_env("TIMSRUST_TEST_DATA_DDA"); + if (dda.empty()) { SKIP("TIMSRUST_TEST_DATA_DDA not set"); } + + tims_dataset* handle = nullptr; + REQUIRE(tims_open(dda.c_str(), &handle) == TIMSFFI_OK); + REQUIRE(handle != nullptr); + + auto n = tims_num_spectra(handle); + REQUIRE(n > 0); + + tims_spectrum spec{}; + REQUIRE(tims_get_spectrum(handle, 0, &spec) == TIMSFFI_OK); + REQUIRE(spec.num_peaks > 0); + REQUIRE(spec.mz[0] > 0.0f); + + tims_close(handle); +} + +// ---- DIA smoke (real data) ---- + +TEST_CASE("DIA smoke test", "[smoke][dia]") { + auto dia = get_env("TIMSRUST_TEST_DATA_DIA"); + if (dia.empty()) { SKIP("TIMSRUST_TEST_DATA_DIA not set"); } + + tims_dataset* handle = nullptr; + REQUIRE(tims_open(dia.c_str(), &handle) == TIMSFFI_OK); + + unsigned int win_count = 0; + tims_swath_window* windows = nullptr; + REQUIRE(tims_get_swath_windows(handle, &win_count, &windows) == TIMSFFI_OK); + REQUIRE(win_count > 0); + + // Get spectra by RT + tims_file_info_t info{}; + tims_file_info(handle, &info); + double mid_rt = (info.ms2.rt_min + info.ms2.rt_max) / 2.0; + + unsigned int spec_count = 0; + tims_spectrum* specs = nullptr; + REQUIRE(tims_get_spectra_by_rt(handle, mid_rt, 3, 0.0, 1e15, &spec_count, &specs) == TIMSFFI_OK); + REQUIRE(spec_count > 0); + + tims_free_spectrum_array(handle, specs, spec_count); + tims_free_swath_windows(handle, windows); + tims_close(handle); +} + +// ---- Config effects (real data) ---- + +TEST_CASE("Centroiding reduces peak count", "[config][dda]") { + auto dda = get_env("TIMSRUST_TEST_DATA_DDA"); + if (dda.empty()) { SKIP("TIMSRUST_TEST_DATA_DDA not set"); } + + // Open with default config + tims_dataset* h_default = nullptr; + REQUIRE(tims_open(dda.c_str(), &h_default) == TIMSFFI_OK); + tims_spectrum spec_default{}; + REQUIRE(tims_get_spectrum(h_default, 0, &spec_default) == TIMSFFI_OK); + auto peaks_default = spec_default.num_peaks; + tims_close(h_default); + + // Open with centroiding + auto* cfg = tims_config_create(); + tims_config_set_centroiding_window(cfg, 10); + tims_dataset* h_centro = nullptr; + REQUIRE(tims_open_with_config(dda.c_str(), cfg, &h_centro) == TIMSFFI_OK); + tims_spectrum spec_centro{}; + REQUIRE(tims_get_spectrum(h_centro, 0, &spec_centro) == TIMSFFI_OK); + auto peaks_centro = spec_centro.num_peaks; + tims_config_free(cfg); + tims_close(h_centro); + + REQUIRE(peaks_centro <= peaks_default); +} + +TEST_CASE("Smoothing changes intensities", "[config][dda]") { + auto dda = get_env("TIMSRUST_TEST_DATA_DDA"); + if (dda.empty()) { SKIP("TIMSRUST_TEST_DATA_DDA not set"); } + + // Open with explicit smoothing_window=0 (baseline) + auto* cfg_base = tims_config_create(); + tims_config_set_smoothing_window(cfg_base, 0); + tims_dataset* h1 = nullptr; + REQUIRE(tims_open_with_config(dda.c_str(), cfg_base, &h1) == TIMSFFI_OK); + tims_config_free(cfg_base); + tims_spectrum s1{}; + REQUIRE(tims_get_spectrum(h1, 0, &s1) == TIMSFFI_OK); + std::vector int1(s1.intensity, s1.intensity + s1.num_peaks); + tims_close(h1); + + // Open with smoothing + auto* cfg = tims_config_create(); + tims_config_set_smoothing_window(cfg, 5); + tims_dataset* h2 = nullptr; + REQUIRE(tims_open_with_config(dda.c_str(), cfg, &h2) == TIMSFFI_OK); + tims_spectrum s2{}; + REQUIRE(tims_get_spectrum(h2, 0, &s2) == TIMSFFI_OK); + std::vector int2(s2.intensity, s2.intensity + s2.num_peaks); + tims_config_free(cfg); + tims_close(h2); + + // At least some intensities should differ + bool any_differ = false; + auto n = std::min(int1.size(), int2.size()); + for (size_t i = 0; i < n; ++i) { + if (std::abs(int1[i] - int2[i]) > 1e-6f) { any_differ = true; break; } + } + REQUIRE(any_differ); +} + +TEST_CASE("Calibration changes m/z values", "[config][dda]") { + auto dda = get_env("TIMSRUST_TEST_DATA_DDA"); + if (dda.empty()) { SKIP("TIMSRUST_TEST_DATA_DDA not set"); } + + // Open without calibration + auto* cfg_off = tims_config_create(); + tims_config_set_calibrate(cfg_off, 0); + tims_dataset* h1 = nullptr; + REQUIRE(tims_open_with_config(dda.c_str(), cfg_off, &h1) == TIMSFFI_OK); + tims_spectrum s1{}; + REQUIRE(tims_get_spectrum(h1, 0, &s1) == TIMSFFI_OK); + std::vector mz1(s1.mz, s1.mz + s1.num_peaks); + tims_config_free(cfg_off); + tims_close(h1); + + // Open with calibration + auto* cfg_on = tims_config_create(); + tims_config_set_calibrate(cfg_on, 1); + tims_dataset* h2 = nullptr; + REQUIRE(tims_open_with_config(dda.c_str(), cfg_on, &h2) == TIMSFFI_OK); + tims_spectrum s2{}; + REQUIRE(tims_get_spectrum(h2, 0, &s2) == TIMSFFI_OK); + std::vector mz2(s2.mz, s2.mz + s2.num_peaks); + tims_config_free(cfg_on); + tims_close(h2); + + // m/z values may or may not differ depending on dataset; + // at minimum neither should crash and both should return valid data + REQUIRE(s1.num_peaks > 0); + REQUIRE(s2.num_peaks > 0); +} + +TEST_CASE("Config combinations produce valid output", "[config][dda]") { + auto dda = get_env("TIMSRUST_TEST_DATA_DDA"); + if (dda.empty()) { SKIP("TIMSRUST_TEST_DATA_DDA not set"); } + + auto* cfg = tims_config_create(); + tims_config_set_smoothing_window(cfg, 3); + tims_config_set_centroiding_window(cfg, 5); + tims_config_set_calibration_tolerance(cfg, 0.01); + tims_config_set_calibrate(cfg, 1); + + tims_dataset* handle = nullptr; + REQUIRE(tims_open_with_config(dda.c_str(), cfg, &handle) == TIMSFFI_OK); + + tims_spectrum spec{}; + REQUIRE(tims_get_spectrum(handle, 0, &spec) == TIMSFFI_OK); + REQUIRE(spec.num_peaks > 0); + + // mz should be sorted + for (uint32_t i = 1; i < spec.num_peaks; ++i) { + REQUIRE(spec.mz[i] >= spec.mz[i - 1]); + } + + // intensities should be non-negative + for (uint32_t i = 0; i < spec.num_peaks; ++i) { + REQUIRE(spec.intensity[i] >= 0.0f); + } + + tims_config_free(cfg); + tims_close(handle); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add tests_cpp/test_smoke.cpp +git commit -m "test: add C++ smoke tests and config effect tests" +``` + +### Task 12: C++ Makefile + +**Files:** +- Create: `tests_cpp/Makefile` + +- [ ] **Step 1: Write the Makefile** + +```makefile +# tests_cpp/Makefile +# +# Build and run C++ FFI tests against libtimsrust_cpp_bridge. +# +# Usage: +# make test LIBDIR=../target/release +# make test LIBDIR=../target/debug +# +# With real data: +# make test LIBDIR=../target/release DDA=/path/to/dda.d DIA=/path/to/dia.d + +CXX ?= g++ +CXXFLAGS := -std=c++17 -Wall -Werror -I../include +LIBDIR ?= ../target/debug +LDFLAGS := -L$(LIBDIR) -ltimsrust_cpp_bridge -Wl,-rpath,$(realpath $(LIBDIR)) + +# Optional dataset paths (empty = skip data tests) +DDA ?= +DIA ?= + +SRCS := test_abi.cpp test_smoke.cpp catch2/catch.cpp +OBJS := $(SRCS:.cpp=.o) +BIN := run_tests + +.PHONY: test clean + +test: $(BIN) + @echo "=== Running C++ FFI tests ===" + TIMSRUST_TEST_DATA_DDA="$(DDA)" TIMSRUST_TEST_DATA_DIA="$(DIA)" \ + ./$(BIN) --reporter compact + +$(BIN): $(OBJS) + $(CXX) $(CXXFLAGS) -o $@ $^ $(LDFLAGS) + +%.o: %.cpp + $(CXX) $(CXXFLAGS) -c -o $@ $< + +clean: + rm -f $(OBJS) $(BIN) +``` + +**IMPORTANT:** Makefile recipe lines (indented lines under targets) MUST use +tab characters, not spaces. When copying from this plan, ensure indentation +is converted to tabs. + +- [ ] **Step 2: Verify Makefile syntax** + +Run: `make -n -f tests_cpp/Makefile` (dry-run to check for syntax errors) + +- [ ] **Step 3: Commit** + +```bash +git add tests_cpp/Makefile +git commit -m "test: add C++ test Makefile" +``` + +## Chunk 5: CI & Final Verification + +### Task 13: GitHub Actions workflow + +**Files:** +- Create: `.github/workflows/test.yml` + +- [ ] **Step 1: Write the CI workflow** + +```yaml +# .github/workflows/test.yml +name: Tests + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +jobs: + stub-tests: + name: Stub Tests (no dataset) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cargo test (stub mode) + run: cargo test -- --nocapture + + - name: Build library (debug) + run: cargo build + + - name: C++ ABI tests + run: | + cd tests_cpp + make test LIBDIR=../target/debug + + integration-tests: + name: Integration Tests (real data) + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' || github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Download test datasets + run: | + # Download from GitHub release artifacts + # Dataset filenames TBD — update these URLs when known + mkdir -p testdata + # gh release download v0.1.0-testdata -p "*.d.tar.gz" -D testdata + # tar -xzf testdata/dda.d.tar.gz -C testdata + # tar -xzf testdata/dia.d.tar.gz -C testdata + echo "TODO: Update with real dataset download commands" + + - name: Build library (release, with timsrust) + run: cargo build --features with_timsrust --release + + - name: Rust integration tests + env: + TIMSRUST_TEST_DATA_DDA: testdata/dda.d + TIMSRUST_TEST_DATA_DIA: testdata/dia.d + run: cargo test --features with_timsrust -- --nocapture + + - name: C++ smoke tests + run: | + cd tests_cpp + make test LIBDIR=../target/release \ + DDA=../testdata/dda.d \ + DIA=../testdata/dia.d +``` + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/test.yml +git commit -m "ci: add GitHub Actions workflow for stub and integration tests" +``` + +### Task 14: Final verification — run all stub tests + +- [ ] **Step 1: Build the library** + +Run: `cargo build` + +- [ ] **Step 2: Run all Rust stub tests** + +Run: `cargo test -- --nocapture` +Expected: All tests pass (real-data tests skip with message) + +- [ ] **Step 3: Build and run C++ ABI tests** + +Run: `cd tests_cpp && make test LIBDIR=../target/debug` +Expected: ABI tests pass, smoke tests skip (no data) + +- [ ] **Step 4: Final commit if any fixups needed** + +```bash +git add -A +git commit -m "test: fixups from final verification" +``` diff --git a/examples/cpp_client.cpp b/examples/cpp_client.cpp index 5e3079e..779a991 100644 --- a/examples/cpp_client.cpp +++ b/examples/cpp_client.cpp @@ -154,6 +154,58 @@ int main(int argc, char** argv) { std::cout << " file_info scan: " << info_ms << " ms (wall)\n"; std::cout << " (timsrust internal wall_ms: " << info.wall_ms << " ms)\n"; + // ---- Frame-level access demo -------------------------------------------- + std::cout << "\n-- Frame-level access --\n"; + unsigned int total_frames = tims_num_frames(handle); + if (total_frames > 0) { + tims_frame frame{}; + timsffi_status fs = tims_get_frame(handle, 0, &frame); + if (fs == TIMSFFI_OK) { + std::cout << "Frame 0: index=" << frame.index + << " rt=" << std::fixed << std::setprecision(2) << frame.rt_seconds << "s" + << " ms_level=" << (int)frame.ms_level + << " scans=" << frame.num_scans + << " peaks=" << frame.num_peaks << "\n"; + } + + // Batch: get all MS1 frames + tims_frame* ms1_frames = nullptr; + unsigned int ms1_count = 0; + auto t_ms1 = Clock::now(); + tims_get_frames_by_level(handle, 1, &ms1_count, &ms1_frames); + double ms1_ms = elapsed_ms(t_ms1); + std::cout << "MS1 frames: " << ms1_count + << " (fetched in " << std::setprecision(1) << ms1_ms << " ms)\n"; + if (ms1_frames) tims_free_frame_array(handle, ms1_frames, ms1_count); + } + + // ---- Converter demo ----------------------------------------------------- + std::cout << "\n-- Converters --\n"; + double mz_example = tims_convert_tof_to_mz(handle, 100000); + double im_example = tims_convert_scan_to_im(handle, 500); + std::cout << "TOF 100000 -> m/z " << std::setprecision(4) << mz_example << "\n"; + std::cout << "Scan 500 -> IM " << std::setprecision(4) << im_example << "\n"; + + // ---- Extended spectrum fields demo -------------------------------------- + std::cout << "\n-- Extended spectrum fields --\n"; + if (tims_num_spectra(handle) > 0) { + tims_spectrum spec{}; + if (tims_get_spectrum(handle, 0, &spec) == TIMSFFI_OK) { + std::cout << "Spectrum 0: index=" << spec.index + << " ms_level=" << (int)spec.ms_level + << " charge=" << (int)spec.charge + << " isolation_width=" << std::setprecision(2) << spec.isolation_width + << " isolation_mz=" << spec.isolation_mz + << " frame_index=" << spec.frame_index + << " precursor_intensity="; + if (std::isnan(spec.precursor_intensity)) + std::cout << "N/A"; + else + std::cout << std::setprecision(0) << spec.precursor_intensity; + std::cout << "\n"; + } + } + tims_close(handle); return 0; } diff --git a/include/timsrust_cpp_bridge.h b/include/timsrust_cpp_bridge.h index 8ab4cd4..7bd6bde 100644 --- a/include/timsrust_cpp_bridge.h +++ b/include/timsrust_cpp_bridge.h @@ -1,4 +1,4 @@ -/* include/timsffi.h */ +/* include/timsrust_cpp_bridge.h */ #ifndef TIMSFFI_H #define TIMSFFI_H @@ -27,6 +27,13 @@ typedef struct { const float* mz; const float* intensity; double im; + /* Sage-parity fields */ + uint32_t index; /* spectrum index from SpectrumReader */ + double isolation_width; /* isolation window width (0.0 if N/A) */ + double isolation_mz; /* isolation window center m/z (0.0 if N/A) */ + uint8_t charge; /* precursor charge (0 = unknown) */ + double precursor_intensity; /* precursor intensity (NaN = unknown) */ + uint32_t frame_index; /* precursor frame index (UINT32_MAX for MS1) */ } tims_spectrum; typedef struct { @@ -38,6 +45,17 @@ typedef struct { uint8_t is_ms1; } tims_swath_window; +typedef struct { + uint32_t index; + double rt_seconds; + uint8_t ms_level; /* 1=MS1, 2=MS2, 0=Unknown */ + uint32_t num_scans; + uint32_t num_peaks; /* total peaks (length of tof_indices & intensities) */ + const uint32_t* tof_indices; /* raw TOF indices, flat array */ + const uint32_t* intensities; /* raw intensities, flat array */ + const uint64_t* scan_offsets;/* per-scan offsets (length: num_scans + 1) */ +} tims_frame; + /* functions: tims_open, tims_close, tims_num_spectra, tims_get_spectrum, ... */ /* Function prototypes (C ABI) * Note: mz/intensity pointers returned from `tims_get_spectrum` currently @@ -143,6 +161,70 @@ typedef struct { */ timsffi_status tims_file_info(tims_dataset* handle, tims_file_info_t* out); +/* ------------------------------------------------------------------------- + * Frame-level access + * ------------------------------------------------------------------------- */ + +/* Fill out a frame structure for the given index. Returns status code. + * Pointers in the output point to internal buffers owned by the handle; + * valid until the next call to tims_get_frame on the same handle or + * tims_close(). Frame and spectrum buffers are independent. + */ +timsffi_status tims_get_frame(tims_dataset* handle, unsigned int index, tims_frame* out_frame); + +/* Retrieve all frames at the given MS level (1 or 2). Returns an + * allocated array in *out_frames and sets *out_count. Caller must free + * with tims_free_frame_array(handle, frames, count). Invalid ms_level + * returns an empty array with TIMSFFI_OK. + */ +timsffi_status tims_get_frames_by_level(tims_dataset* handle, uint8_t ms_level, unsigned int* out_count, tims_frame** out_frames); + +/* Free frames previously returned by tims_get_frames_by_level. Frees each + * per-frame tof_indices/intensities/scan_offsets buffer and then the array. + */ +void tims_free_frame_array(tims_dataset* handle, tims_frame* frames, unsigned int count); + +/* ------------------------------------------------------------------------- + * Index converters (TOF -> m/z, scan -> ion mobility) + * ------------------------------------------------------------------------- */ + +/* Convert a single TOF index to m/z. Returns NaN if handle is NULL. */ +double tims_convert_tof_to_mz(const tims_dataset* handle, uint32_t tof_index); + +/* Convert a single scan index to ion mobility (1/K0). Returns NaN if handle is NULL. */ +double tims_convert_scan_to_im(const tims_dataset* handle, uint32_t scan_index); + +/* Batch convert TOF indices to m/z. Caller provides output buffer. */ +timsffi_status tims_convert_tof_to_mz_array(const tims_dataset* handle, + const uint32_t* tof_indices, uint32_t count, + double* out_mz); + +/* Batch convert scan indices to ion mobility. Caller provides output buffer. */ +timsffi_status tims_convert_scan_to_im_array(const tims_dataset* handle, + const uint32_t* scan_indices, uint32_t count, + double* out_im); + +/* ------------------------------------------------------------------------- + * Opaque configuration for SpectrumReader construction + * ------------------------------------------------------------------------- */ + +typedef struct tims_config tims_config; + +/* Create a new config with default values. Caller must free with tims_config_free. */ +tims_config *tims_config_create(void); + +/* Free a config created by tims_config_create. */ +void tims_config_free(tims_config *cfg); + +/* SpectrumProcessingParams setters */ +void tims_config_set_smoothing_window(tims_config *cfg, uint32_t window); +void tims_config_set_centroiding_window(tims_config *cfg, uint32_t window); +void tims_config_set_calibration_tolerance(tims_config *cfg, double tolerance); +void tims_config_set_calibrate(tims_config *cfg, uint8_t enabled); /* 0=off, non-zero=on */ + +/* Open dataset with custom config. Existing tims_open uses defaults. */ +timsffi_status tims_open_with_config(const char* path, const tims_config* cfg, tims_dataset** out); + #ifdef __cplusplus } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..cfdf573 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,61 @@ +// src/config.rs +// +// Opaque config builder wrapping timsrust's SpectrumReaderConfig. +// Used by tims_open_with_config() to customize reader construction. + +#[cfg(feature = "with_timsrust")] +use timsrust::readers::SpectrumReaderConfig; + +/// FFI-facing config wrapper. When with_timsrust is enabled, wraps the +/// real SpectrumReaderConfig. Otherwise a dummy struct for API compat. +pub struct TimsFfiConfig { + #[cfg(feature = "with_timsrust")] + pub(crate) inner: SpectrumReaderConfig, + + #[cfg(not(feature = "with_timsrust"))] + _dummy: (), +} + +impl TimsFfiConfig { + pub fn new() -> Self { + TimsFfiConfig { + #[cfg(feature = "with_timsrust")] + inner: SpectrumReaderConfig::default(), + + #[cfg(not(feature = "with_timsrust"))] + _dummy: (), + } + } + + #[cfg(feature = "with_timsrust")] + pub fn set_smoothing_window(&mut self, window: u32) { + self.inner.spectrum_processing_params.smoothing_window = window; + } + + #[cfg(not(feature = "with_timsrust"))] + pub fn set_smoothing_window(&mut self, _window: u32) {} + + #[cfg(feature = "with_timsrust")] + pub fn set_centroiding_window(&mut self, window: u32) { + self.inner.spectrum_processing_params.centroiding_window = window; + } + + #[cfg(not(feature = "with_timsrust"))] + pub fn set_centroiding_window(&mut self, _window: u32) {} + + #[cfg(feature = "with_timsrust")] + pub fn set_calibration_tolerance(&mut self, tolerance: f64) { + self.inner.spectrum_processing_params.calibration_tolerance = tolerance; + } + + #[cfg(not(feature = "with_timsrust"))] + pub fn set_calibration_tolerance(&mut self, _tolerance: f64) {} + + #[cfg(feature = "with_timsrust")] + pub fn set_calibrate(&mut self, enabled: bool) { + self.inner.spectrum_processing_params.calibrate = enabled; + } + + #[cfg(not(feature = "with_timsrust"))] + pub fn set_calibrate(&mut self, _enabled: bool) {} +} diff --git a/src/dataset.rs b/src/dataset.rs index f5bf2f8..e1aff4f 100644 --- a/src/dataset.rs +++ b/src/dataset.rs @@ -1,5 +1,5 @@ // src/dataset.rs -use crate::types::{TimsFfiSpectrum, TimsFfiSwathWindow, TimsFfiStatus}; +use crate::types::{TimsFfiSpectrum, TimsFfiSwathWindow, TimsFfiFrame, TimsFfiStatus}; use std::ffi::CStr; use std::ptr; @@ -14,9 +14,13 @@ use timsrust::readers::SpectrumReader; use timsrust::readers::MetadataReader; #[cfg(feature = "with_timsrust")] use timsrust::converters::ConvertableDomain; +#[cfg(feature = "with_timsrust")] +use timsrust::readers::FrameReader; +#[cfg(feature = "with_timsrust")] +use timsrust::converters::{Tof2MzConverter, Scan2ImConverter}; #[cfg(not(feature = "with_timsrust"))] -struct SpectrumReader { +pub(crate) struct SpectrumReader { // minimal stub keeps a length (0) to allow basic API tests n: usize, } @@ -32,116 +36,179 @@ impl SpectrumReader { } } +#[cfg(not(feature = "with_timsrust"))] +pub(crate) struct FrameReader { n: usize } + +#[cfg(not(feature = "with_timsrust"))] +impl FrameReader { + fn new(_path: &str) -> Result { Ok(FrameReader { n: 0 }) } + fn len(&self) -> usize { self.n } +} + +#[cfg(not(feature = "with_timsrust"))] +pub(crate) struct Tof2MzConverter; +#[cfg(not(feature = "with_timsrust"))] +pub(crate) struct Scan2ImConverter; +#[cfg(not(feature = "with_timsrust"))] +impl Tof2MzConverter { pub fn convert(&self, value: f64) -> f64 { value } } +#[cfg(not(feature = "with_timsrust"))] +impl Scan2ImConverter { pub fn convert(&self, value: f64) -> f64 { value } } + pub struct TimsDataset { + /// Spectrum-level reader (DDA/DIA expanded spectra). pub(crate) reader: SpectrumReader, - // Reusable buffers for mz/intensity to keep ownership in Rust + /// Reusable buffer for spectrum m/z values (handle-owned). mz_buf: Vec, + /// Reusable buffer for spectrum intensity values (handle-owned). int_buf: Vec, - // Optional cached swath windows computed at open time when available + /// Reusable buffer for frame TOF indices (handle-owned, single-frame API). + frame_tof_buf: Vec, + /// Reusable buffer for frame intensities (handle-owned, single-frame API). + frame_int_buf: Vec, + /// Reusable buffer for frame scan offsets (handle-owned, single-frame API). + frame_scan_offset_buf: Vec, + /// Raw frame reader for MS1/MS2 frame-level access. + pub(crate) frame_reader: FrameReader, + /// TOF index → m/z converter, cached from MetadataReader at open time. + pub(crate) mz_converter: Tof2MzConverter, + /// Scan index → ion mobility converter, cached from MetadataReader at open time. + pub(crate) im_converter: Scan2ImConverter, + /// Precomputed DIA isolation windows (None if unavailable). swath_windows: Option>, - // Optional last error string for this handle + /// Last error message for this handle (per-handle error storage). pub(crate) last_error: Option, - // Total raw LC frame count (MS1 + MS2); 0 if not available - num_frames: u32, - // RT index: sorted (rt_seconds, spectrum_index) pairs for fast lookup + /// Sorted (rt_seconds, spectrum_index) pairs for fast RT lookup. rt_index: Vec<(f64, usize)>, - // Later: cached DIA windows, MS1/MS2 counts, etc. } impl TimsDataset { pub fn open(path: &CStr) -> Result { let path_str = path.to_str().map_err(|_| TimsFfiStatus::InvalidUtf8)?; - // When using the real timsrust reader, SpectrumReader::new accepts a - // &str and returns a Result. Our stub above uses - // a CStr-based new signature; handle both surfaces. + #[cfg(feature = "with_timsrust")] - let reader = SpectrumReader::new(path_str) - .map_err(|_| TimsFfiStatus::OpenFailed)?; + { + let reader = SpectrumReader::new(path_str) + .map_err(|_| TimsFfiStatus::OpenFailed)?; + return Self::finish_open(path_str, reader); + } #[cfg(not(feature = "with_timsrust"))] - let reader = SpectrumReader::new(path) + { + let reader = SpectrumReader::new(path) + .map_err(|_| TimsFfiStatus::OpenFailed)?; + let fr = FrameReader::new(path_str).map_err(|_| TimsFfiStatus::OpenFailed)?; + Ok(TimsDataset { + reader, + mz_buf: Vec::new(), + int_buf: Vec::new(), + frame_tof_buf: Vec::new(), + frame_int_buf: Vec::new(), + frame_scan_offset_buf: Vec::new(), + frame_reader: fr, + mz_converter: Tof2MzConverter, + im_converter: Scan2ImConverter, + swath_windows: None, + last_error: None, + rt_index: Vec::new(), + }) + } + } + + /// Common post-reader setup: reads metadata, builds swath windows, RT + /// index, and frame reader. Only compiled with the real timsrust feature. + #[cfg(feature = "with_timsrust")] + fn finish_open(path_str: &str, reader: SpectrumReader) -> Result { + use timsrust::readers::QuadrupoleSettingsReader; + use timsrust::readers::PrecursorReader; + + let fr = FrameReader::new(path_str) .map_err(|_| TimsFfiStatus::OpenFailed)?; - // Attempt to compute swath windows when timsrust feature is enabled. - #[cfg(feature = "with_timsrust")] - let (swath_windows, num_frames) = { - use timsrust::readers::QuadrupoleSettingsReader; - use timsrust::readers::FrameReader; - let mut out: Vec = Vec::new(); - let nf = FrameReader::new(path_str) - .map(|fr| fr.len() as u32) - .unwrap_or(0); - if let Ok(quads) = QuadrupoleSettingsReader::new(path_str) { - if let Ok(metadata) = timsrust::readers::MetadataReader::new(path_str) { - let im_converter = metadata.im_converter; - for quad in quads.iter() { - for i in 0..quad.len() { - let center = quad.isolation_mz[i]; - let width = quad.isolation_width[i]; - let mz_lower = center - width / 2.0; - let mz_upper = center + width / 2.0; - let (im_lower, im_upper) = if i < quad.scan_starts.len() && i < quad.scan_ends.len() { - let start = quad.scan_starts[i] as f64; - let end = quad.scan_ends[i] as f64; - let im_start = im_converter.convert(start); - let im_end = im_converter.convert(end); - (im_start.min(im_end), im_start.max(im_end)) - } else { - (metadata.lower_im, metadata.upper_im) - }; - out.push(TimsFfiSwathWindow { - mz_lower, - mz_upper, - mz_center: center, - im_lower, - im_upper, - is_ms1: 0, - }); - } - } - } - } - (if out.is_empty() { None } else { Some(out) }, nf) - }; - #[cfg(not(feature = "with_timsrust"))] - let (swath_windows, num_frames) = (None, 0u32); + let metadata = MetadataReader::new(path_str) + .map_err(|_| TimsFfiStatus::OpenFailed)?; + let mz_conv = metadata.mz_converter; + let im_conv = metadata.im_converter; - // Build RT index: cheaply read per-precursor RT without decompressing - // peak data, so we can do fast nearest-RT lookups later. - #[cfg(feature = "with_timsrust")] - let rt_index = { - use timsrust::readers::PrecursorReader; - let mut idx: Vec<(f64, usize)> = Vec::new(); - if let Ok(prec_reader) = PrecursorReader::build() - .with_path(path_str) - .finalize() - { - let n = prec_reader.len(); - idx.reserve(n); - for i in 0..n { - if let Some(p) = prec_reader.get(i) { - idx.push((p.rt, i)); - } + let mut sw_out: Vec = Vec::new(); + if let Ok(quads) = QuadrupoleSettingsReader::new(path_str) { + for quad in quads.iter() { + for i in 0..quad.len() { + let center = quad.isolation_mz[i]; + let width = quad.isolation_width[i]; + let mz_lower = center - width / 2.0; + let mz_upper = center + width / 2.0; + let (im_lower, im_upper) = if i < quad.scan_starts.len() && i < quad.scan_ends.len() { + let start = quad.scan_starts[i] as f64; + let end = quad.scan_ends[i] as f64; + let im_start = im_conv.convert(start); + let im_end = im_conv.convert(end); + (im_start.min(im_end), im_start.max(im_end)) + } else { + (metadata.lower_im, metadata.upper_im) + }; + sw_out.push(TimsFfiSwathWindow { + mz_lower, + mz_upper, + mz_center: center, + im_lower, + im_upper, + is_ms1: 0, + }); } - idx.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); } - idx - }; + } + let swath_windows = if sw_out.is_empty() { None } else { Some(sw_out) }; - #[cfg(not(feature = "with_timsrust"))] - let rt_index: Vec<(f64, usize)> = Vec::new(); + // Build RT index + let mut rt_index: Vec<(f64, usize)> = Vec::new(); + if let Ok(prec_reader) = PrecursorReader::build() + .with_path(path_str) + .finalize() + { + let n = prec_reader.len(); + rt_index.reserve(n); + for i in 0..n { + if let Some(p) = prec_reader.get(i) { + rt_index.push((p.rt, i)); + } + } + rt_index.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + } Ok(TimsDataset { reader, mz_buf: Vec::new(), int_buf: Vec::new(), + frame_tof_buf: Vec::new(), + frame_int_buf: Vec::new(), + frame_scan_offset_buf: Vec::new(), + frame_reader: fr, + mz_converter: mz_conv, + im_converter: im_conv, swath_windows, last_error: None, - num_frames, rt_index, }) } + /// Open a dataset with a custom SpectrumReaderConfig. + #[cfg(feature = "with_timsrust")] + pub fn open_with_config(path: &CStr, config: &crate::config::TimsFfiConfig) -> Result { + let path_str = path.to_str().map_err(|_| TimsFfiStatus::InvalidUtf8)?; + let reader = timsrust::readers::SpectrumReader::build() + .with_path(path_str) + .with_config(config.inner.clone()) + .finalize() + .map_err(|_| TimsFfiStatus::OpenFailed)?; + Self::finish_open(path_str, reader) + } + + /// Stub: open_with_config delegates to open when timsrust is not enabled. + #[cfg(not(feature = "with_timsrust"))] + pub fn open_with_config(path: &CStr, _config: &crate::config::TimsFfiConfig) -> Result { + Self::open(path) + } + pub fn len(&self) -> u32 { self.reader.len() as u32 } @@ -149,7 +216,7 @@ impl TimsDataset { /// Number of raw LC frames (MS1 + MS2). Only available with timsrust. /// This counts all frames in the acquisition, not the expanded DIA spectra. pub fn num_frames(&self) -> u32 { - self.num_frames + self.frame_reader.len() as u32 } /// Sorted (rt_seconds, spectrum_index) pairs — built at open time from @@ -170,11 +237,9 @@ impl TimsDataset { // Real implementation when timsrust feature is enabled. #[cfg(feature = "with_timsrust")] { - // Fetch spectrum from timsrust let spec = self.reader.get(index as usize) .map_err(|_| TimsFfiStatus::IndexOutOfBounds)?; - // Convert mz/int to f32 buffers owned by this dataset handle. let n = spec.len(); self.mz_buf.clear(); self.mz_buf.reserve(n); @@ -187,22 +252,29 @@ impl TimsDataset { self.int_buf.push(v as f32); } - // Fill output struct out.num_peaks = n as u32; out.mz = if n == 0 { ptr::null() } else { self.mz_buf.as_ptr() }; out.intensity = if n == 0 { ptr::null() } else { self.int_buf.as_ptr() }; + out.index = spec.index as u32; + out.isolation_width = spec.isolation_width; + out.isolation_mz = spec.isolation_mz; - // Metadata: try to extract precursor RT/IM if available if let Some(prec) = spec.precursor { out.rt_seconds = prec.rt; out.precursor_mz = prec.mz; out.im = prec.im; out.ms_level = 2; + out.charge = prec.charge.map(|c| c as u8).unwrap_or(0); + out.precursor_intensity = prec.intensity.unwrap_or(f64::NAN); + out.frame_index = prec.frame_index as u32; } else { out.rt_seconds = 0.0; out.precursor_mz = 0.0; out.im = 0.0; out.ms_level = 1; + out.charge = 0; + out.precursor_intensity = f64::NAN; + out.frame_index = u32::MAX; } return Ok(()); @@ -218,6 +290,12 @@ impl TimsDataset { out.mz = ptr::null(); out.intensity = ptr::null(); out.im = 0.0; + out.index = 0; + out.isolation_width = 0.0; + out.isolation_mz = 0.0; + out.charge = 0; + out.precursor_intensity = f64::NAN; + out.frame_index = u32::MAX; Ok(()) } } @@ -247,5 +325,63 @@ impl TimsDataset { // No metadata available: return empty vector. Ok(vec![]) } + + pub fn get_frame(&mut self, index: u32, out: &mut TimsFfiFrame) -> Result<(), TimsFfiStatus> { + let len = self.frame_reader.len() as u32; + if index >= len { + self.last_error = Some(format!( + "frame index {} out of bounds (total {})", index, len + )); + return Err(TimsFfiStatus::IndexOutOfBounds); + } + + #[cfg(feature = "with_timsrust")] + { + let frame = self.frame_reader.get(index as usize) + .map_err(|e| { + self.last_error = Some(format!("failed to read frame {}: {:?}", index, e)); + TimsFfiStatus::Internal + })?; + self.frame_tof_buf.clear(); + self.frame_tof_buf.extend_from_slice(&frame.tof_indices); + self.frame_int_buf.clear(); + self.frame_int_buf.extend_from_slice(&frame.intensities); + self.frame_scan_offset_buf.clear(); + self.frame_scan_offset_buf.extend(frame.scan_offsets.iter().map(|&s| s as u64)); + let num_scans = if frame.scan_offsets.is_empty() { + 0 + } else { + (frame.scan_offsets.len() - 1) as u32 + }; + out.index = frame.index as u32; + out.rt_seconds = frame.rt_in_seconds; + out.ms_level = match frame.ms_level { + timsrust::MSLevel::MS1 => 1, + timsrust::MSLevel::MS2 => 2, + _ => 0, + }; + out.num_scans = num_scans; + out.num_peaks = frame.tof_indices.len() as u32; + out.tof_indices = if out.num_peaks == 0 { ptr::null() } else { self.frame_tof_buf.as_ptr() }; + out.intensities = if out.num_peaks == 0 { ptr::null() } else { self.frame_int_buf.as_ptr() }; + out.scan_offsets = if num_scans == 0 { ptr::null() } else { self.frame_scan_offset_buf.as_ptr() }; + self.last_error = None; + return Ok(()); + } + + #[cfg(not(feature = "with_timsrust"))] + { + out.index = index; + out.rt_seconds = 0.0; + out.ms_level = 0; + out.num_scans = 0; + out.num_peaks = 0; + out.tof_indices = ptr::null(); + out.intensities = ptr::null(); + out.scan_offsets = ptr::null(); + self.last_error = None; + Ok(()) + } + } } diff --git a/src/lib.rs b/src/lib.rs index cf9321a..ae4899e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,11 @@ // src/lib.rs -mod dataset; -mod types; +pub mod config; +pub mod dataset; +pub mod types; +use crate::config::TimsFfiConfig; use crate::dataset::TimsDataset; -use crate::types::{TimsFfiSpectrum, TimsFfiStatus, TimsFfiFileInfo, TimsFfiLevelStats}; +use crate::types::{TimsFfiSpectrum, TimsFfiFrame, TimsFfiStatus, TimsFfiFileInfo, TimsFfiLevelStats}; use std::ffi::CStr; use std::os::raw::{c_char, c_uint}; use std::os::raw::{c_int, c_double}; @@ -16,6 +18,9 @@ use once_cell::sync::Lazy; use std::sync::Mutex; use crate::types::TIMSFFI_MAX_ERROR_LEN; +#[cfg(feature = "with_timsrust")] +use timsrust::converters::ConvertableDomain; + static LAST_ERROR: Lazy>> = Lazy::new(|| Mutex::new(None)); #[repr(C)] @@ -210,20 +215,20 @@ pub extern "C" fn tims_get_spectra_by_rt( out_count: *mut c_uint, out_specs: *mut *mut TimsFfiSpectrum, ) -> TimsFfiStatus { + if handle.is_null() || out_count.is_null() || out_specs.is_null() { + return TimsFfiStatus::Internal; + } + // This API is only available when built with the real timsrust feature. #[cfg(not(feature = "with_timsrust"))] { // Feature not enabled: return empty result - if out_count.is_null() || out_specs.is_null() { return TimsFfiStatus::Internal; } unsafe { *out_count = 0; *out_specs = std::ptr::null_mut(); } return TimsFfiStatus::Ok; } #[cfg(feature = "with_timsrust")] { - if handle.is_null() || out_count.is_null() || out_specs.is_null() { - return TimsFfiStatus::Internal; - } let ds = unsafe { &mut (*handle).inner }; // Fast RT lookup using the sorted RT index built at open time. @@ -320,6 +325,12 @@ pub extern "C" fn tims_get_spectra_by_rt( mz: if mz_ptr.is_null() { std::ptr::null() } else { mz_ptr as *const f32 }, intensity: if int_ptr.is_null() { std::ptr::null() } else { int_ptr as *const f32 }, im: spec.precursor.map(|p| p.im).unwrap_or(0.0), + index: spec.index as u32, + isolation_width: spec.isolation_width, + isolation_mz: spec.isolation_mz, + charge: spec.precursor.and_then(|p| p.charge).map(|c| c as u8).unwrap_or(0), + precursor_intensity: spec.precursor.and_then(|p| p.intensity).unwrap_or(f64::NAN), + frame_index: spec.precursor.map(|p| p.frame_index as u32).unwrap_or(u32::MAX), }; unsafe { arr_ptr.add(idx).write(out_spec); } } @@ -407,11 +418,427 @@ pub extern "C" fn tims_file_info( } } - info.ms1.finalize(); - info.ms2.finalize(); info.wall_ms = t0.elapsed().as_secs_f64() * 1000.0; } + info.ms1.finalize(); + info.ms2.finalize(); unsafe { *out = info; } TimsFfiStatus::Ok } + +#[no_mangle] +pub extern "C" fn tims_get_frame( + handle: *mut tims_dataset, + index: c_uint, + out_frame: *mut TimsFfiFrame, +) -> TimsFfiStatus { + if handle.is_null() || out_frame.is_null() { + return TimsFfiStatus::Internal; + } + let ds = unsafe { &mut (*handle).inner }; + let out = unsafe { &mut *out_frame }; + match ds.get_frame(index, out) { + Ok(()) => TimsFfiStatus::Ok, + Err(e) => e, + } +} + +#[no_mangle] +pub extern "C" fn tims_get_frames_by_level( + handle: *mut tims_dataset, + ms_level: u8, + out_count: *mut c_uint, + out_frames: *mut *mut TimsFfiFrame, +) -> TimsFfiStatus { + if handle.is_null() || out_count.is_null() || out_frames.is_null() { + return TimsFfiStatus::Internal; + } + let ds = unsafe { &mut (*handle).inner }; + + #[cfg(feature = "with_timsrust")] + { + let frames_result = match ms_level { + 1 => ds.frame_reader.get_all_ms1(), + 2 => ds.frame_reader.get_all_ms2(), + _ => { + // Invalid ms_level: return empty Ok + ds.last_error = None; + unsafe { *out_count = 0; *out_frames = std::ptr::null_mut(); } + return TimsFfiStatus::Ok; + } + }; + + let frames: Vec<_> = match frames_result.into_iter().collect::, _>>() { + Ok(v) => v, + Err(_) => { + ds.last_error = Some("failed to read one or more frames".to_string()); + unsafe { *out_count = 0; *out_frames = std::ptr::null_mut(); } + return TimsFfiStatus::Internal; + } + }; + + let n = frames.len(); + if n == 0 { + ds.last_error = None; + unsafe { *out_count = 0; *out_frames = std::ptr::null_mut(); } + return TimsFfiStatus::Ok; + } + + // Allocate the outer array of TimsFfiFrame via malloc + let arr_size = match n.checked_mul(mem::size_of::()) { + Some(s) => s, + None => return TimsFfiStatus::Internal, + }; + let arr_ptr = unsafe { malloc(arr_size) } as *mut TimsFfiFrame; + if arr_ptr.is_null() { return TimsFfiStatus::Internal; } + + for (idx, frame) in frames.iter().enumerate() { + let num_peaks = frame.tof_indices.len(); + let num_scans = if frame.scan_offsets.is_empty() { 0 } else { frame.scan_offsets.len() - 1 }; + + // Allocate per-frame tof_indices + let tof_ptr = if num_peaks == 0 { std::ptr::null_mut() } else { + let alloc_size = match num_peaks.checked_mul(mem::size_of::()) { + Some(s) => s, + None => { + for j in 0..idx { + unsafe { + let old = arr_ptr.add(j).read(); + if !old.tof_indices.is_null() { free(old.tof_indices as *mut libc::c_void); } + if !old.intensities.is_null() { free(old.intensities as *mut libc::c_void); } + if !old.scan_offsets.is_null() { free(old.scan_offsets as *mut libc::c_void); } + } + } + unsafe { free(arr_ptr as *mut libc::c_void); } + return TimsFfiStatus::Internal; + } + }; + let p = unsafe { malloc(alloc_size) } as *mut u32; + if p.is_null() { + // Free previously allocated frames + for j in 0..idx { + unsafe { + let old = arr_ptr.add(j).read(); + if !old.tof_indices.is_null() { free(old.tof_indices as *mut libc::c_void); } + if !old.intensities.is_null() { free(old.intensities as *mut libc::c_void); } + if !old.scan_offsets.is_null() { free(old.scan_offsets as *mut libc::c_void); } + } + } + unsafe { free(arr_ptr as *mut libc::c_void); } + return TimsFfiStatus::Internal; + } + for i in 0..num_peaks { unsafe { *p.add(i) = frame.tof_indices[i]; } } + p + }; + + // Allocate per-frame intensities + let int_ptr = if num_peaks == 0 { std::ptr::null_mut() } else { + let alloc_size = match num_peaks.checked_mul(mem::size_of::()) { + Some(s) => s, + None => { + if !tof_ptr.is_null() { unsafe { free(tof_ptr as *mut libc::c_void); } } + for j in 0..idx { + unsafe { + let old = arr_ptr.add(j).read(); + if !old.tof_indices.is_null() { free(old.tof_indices as *mut libc::c_void); } + if !old.intensities.is_null() { free(old.intensities as *mut libc::c_void); } + if !old.scan_offsets.is_null() { free(old.scan_offsets as *mut libc::c_void); } + } + } + unsafe { free(arr_ptr as *mut libc::c_void); } + return TimsFfiStatus::Internal; + } + }; + let p = unsafe { malloc(alloc_size) } as *mut u32; + if p.is_null() { + if !tof_ptr.is_null() { unsafe { free(tof_ptr as *mut libc::c_void); } } + for j in 0..idx { + unsafe { + let old = arr_ptr.add(j).read(); + if !old.tof_indices.is_null() { free(old.tof_indices as *mut libc::c_void); } + if !old.intensities.is_null() { free(old.intensities as *mut libc::c_void); } + if !old.scan_offsets.is_null() { free(old.scan_offsets as *mut libc::c_void); } + } + } + unsafe { free(arr_ptr as *mut libc::c_void); } + return TimsFfiStatus::Internal; + } + for i in 0..num_peaks { unsafe { *p.add(i) = frame.intensities[i]; } } + p + }; + + // Allocate per-frame scan_offsets (length: num_scans + 1) + let scan_ptr = if num_scans == 0 { std::ptr::null_mut() } else { + let scan_len = num_scans + 1; + let alloc_size = match scan_len.checked_mul(mem::size_of::()) { + Some(s) => s, + None => { + if !tof_ptr.is_null() { unsafe { free(tof_ptr as *mut libc::c_void); } } + if !int_ptr.is_null() { unsafe { free(int_ptr as *mut libc::c_void); } } + for j in 0..idx { + unsafe { + let old = arr_ptr.add(j).read(); + if !old.tof_indices.is_null() { free(old.tof_indices as *mut libc::c_void); } + if !old.intensities.is_null() { free(old.intensities as *mut libc::c_void); } + if !old.scan_offsets.is_null() { free(old.scan_offsets as *mut libc::c_void); } + } + } + unsafe { free(arr_ptr as *mut libc::c_void); } + return TimsFfiStatus::Internal; + } + }; + let p = unsafe { malloc(alloc_size) } as *mut u64; + if p.is_null() { + if !tof_ptr.is_null() { unsafe { free(tof_ptr as *mut libc::c_void); } } + if !int_ptr.is_null() { unsafe { free(int_ptr as *mut libc::c_void); } } + for j in 0..idx { + unsafe { + let old = arr_ptr.add(j).read(); + if !old.tof_indices.is_null() { free(old.tof_indices as *mut libc::c_void); } + if !old.intensities.is_null() { free(old.intensities as *mut libc::c_void); } + if !old.scan_offsets.is_null() { free(old.scan_offsets as *mut libc::c_void); } + } + } + unsafe { free(arr_ptr as *mut libc::c_void); } + return TimsFfiStatus::Internal; + } + for i in 0..scan_len { unsafe { *p.add(i) = frame.scan_offsets[i] as u64; } } + p + }; + + let ms_lvl: u8 = match frame.ms_level { + timsrust::MSLevel::MS1 => 1, + timsrust::MSLevel::MS2 => 2, + _ => 0, + }; + + let out_frame = TimsFfiFrame { + index: frame.index as u32, + rt_seconds: frame.rt_in_seconds, + ms_level: ms_lvl, + num_scans: num_scans as u32, + num_peaks: num_peaks as u32, + tof_indices: if tof_ptr.is_null() { std::ptr::null() } else { tof_ptr as *const u32 }, + intensities: if int_ptr.is_null() { std::ptr::null() } else { int_ptr as *const u32 }, + scan_offsets: if scan_ptr.is_null() { std::ptr::null() } else { scan_ptr as *const u64 }, + }; + unsafe { arr_ptr.add(idx).write(out_frame); } + } + + ds.last_error = None; + unsafe { *out_count = n as c_uint; *out_frames = arr_ptr; } + TimsFfiStatus::Ok + } + + #[cfg(not(feature = "with_timsrust"))] + { + ds.last_error = None; + unsafe { *out_count = 0; *out_frames = std::ptr::null_mut(); } + TimsFfiStatus::Ok + } +} + +#[no_mangle] +pub extern "C" fn tims_free_frame_array( + _handle: *mut tims_dataset, + frames: *mut TimsFfiFrame, + count: c_uint, +) { + if frames.is_null() { return; } + let n = count as usize; + for i in 0..n { + unsafe { + let fr = frames.add(i).read(); + if !fr.tof_indices.is_null() { free(fr.tof_indices as *mut libc::c_void); } + if !fr.intensities.is_null() { free(fr.intensities as *mut libc::c_void); } + if !fr.scan_offsets.is_null() { free(fr.scan_offsets as *mut libc::c_void); } + } + } + unsafe { free(frames as *mut libc::c_void); } +} + +// ------------------------------------------------------------------------- +// Index converters (TOF -> m/z, scan -> ion mobility) +// ------------------------------------------------------------------------- + +#[no_mangle] +pub extern "C" fn tims_convert_tof_to_mz( + handle: *const tims_dataset, + tof_index: c_uint, +) -> c_double { + if handle.is_null() { return f64::NAN; } + let ds = unsafe { &(*handle).inner }; + ds.mz_converter.convert(tof_index as f64) +} + +#[no_mangle] +pub extern "C" fn tims_convert_scan_to_im( + handle: *const tims_dataset, + scan_index: c_uint, +) -> c_double { + if handle.is_null() { return f64::NAN; } + let ds = unsafe { &(*handle).inner }; + ds.im_converter.convert(scan_index as f64) +} + +#[no_mangle] +pub extern "C" fn tims_convert_tof_to_mz_array( + handle: *const tims_dataset, + tof_indices: *const u32, + count: c_uint, + out_mz: *mut c_double, +) -> TimsFfiStatus { + if handle.is_null() || tof_indices.is_null() || out_mz.is_null() { + return TimsFfiStatus::Internal; + } + let ds = unsafe { &(*handle).inner }; + let n = count as usize; + for i in 0..n { + let idx = unsafe { *tof_indices.add(i) }; + unsafe { *out_mz.add(i) = ds.mz_converter.convert(idx as f64); } + } + TimsFfiStatus::Ok +} + +#[no_mangle] +pub extern "C" fn tims_convert_scan_to_im_array( + handle: *const tims_dataset, + scan_indices: *const u32, + count: c_uint, + out_im: *mut c_double, +) -> TimsFfiStatus { + if handle.is_null() || scan_indices.is_null() || out_im.is_null() { + return TimsFfiStatus::Internal; + } + let ds = unsafe { &(*handle).inner }; + let n = count as usize; + for i in 0..n { + let idx = unsafe { *scan_indices.add(i) }; + unsafe { *out_im.add(i) = ds.im_converter.convert(idx as f64); } + } + TimsFfiStatus::Ok +} + +// ------------------------------------------------------------------------- +// Opaque configuration for SpectrumReader construction +// ------------------------------------------------------------------------- + +/// Opaque config type for C callers. +#[repr(C)] +pub struct tims_config { + inner: TimsFfiConfig, +} + +#[no_mangle] +pub extern "C" fn tims_config_create() -> *mut tims_config { + let cfg = Box::new(tims_config { + inner: TimsFfiConfig::new(), + }); + Box::into_raw(cfg) +} + +#[no_mangle] +pub extern "C" fn tims_config_free(cfg: *mut tims_config) { + if cfg.is_null() { return; } + unsafe { drop(Box::from_raw(cfg)); } +} + +#[no_mangle] +pub extern "C" fn tims_config_set_smoothing_window(cfg: *mut tims_config, window: c_uint) { + if cfg.is_null() { return; } + let c = unsafe { &mut (*cfg).inner }; + c.set_smoothing_window(window); +} + +#[no_mangle] +pub extern "C" fn tims_config_set_centroiding_window(cfg: *mut tims_config, window: c_uint) { + if cfg.is_null() { return; } + let c = unsafe { &mut (*cfg).inner }; + c.set_centroiding_window(window); +} + +#[no_mangle] +pub extern "C" fn tims_config_set_calibration_tolerance(cfg: *mut tims_config, tolerance: c_double) { + if cfg.is_null() { return; } + let c = unsafe { &mut (*cfg).inner }; + c.set_calibration_tolerance(tolerance); +} + +#[no_mangle] +pub extern "C" fn tims_config_set_calibrate(cfg: *mut tims_config, enabled: u8) { + if cfg.is_null() { return; } + let c = unsafe { &mut (*cfg).inner }; + c.set_calibrate(enabled != 0); +} + +#[no_mangle] +pub extern "C" fn tims_open_with_config( + path: *const c_char, + cfg: *const tims_config, + out_handle: *mut *mut tims_dataset, +) -> TimsFfiStatus { + if path.is_null() || cfg.is_null() || out_handle.is_null() { + return TimsFfiStatus::Internal; + } + let cstr = unsafe { CStr::from_ptr(path) }; + let path_str = match cstr.to_str() { + Ok(s) => s, + Err(_) => return TimsFfiStatus::InvalidUtf8, + }; + if std::fs::metadata(path_str).is_err() { + if let Ok(mut g) = LAST_ERROR.lock() { *g = Some(format!("path not found: {}", path_str)); } + return TimsFfiStatus::OpenFailed; + } + + let path_owned = path_str.to_string(); + + // Clone config data for the thread. For the with_timsrust build we + // extract the inner SpectrumReaderConfig which is Send. For the stub + // build we just create a fresh dummy inside the thread. + #[cfg(feature = "with_timsrust")] + let config_inner = unsafe { (*cfg).inner.inner.clone() }; + + let (tx, rx) = mpsc::channel(); + let thr = thread::spawn(move || { + let c = CString::new(path_owned).unwrap(); + let res = std::panic::catch_unwind(|| { + let mut cfg_local = TimsFfiConfig::new(); + #[cfg(feature = "with_timsrust")] + { cfg_local.inner = config_inner; } + TimsDataset::open_with_config(c.as_c_str(), &cfg_local) + }); + match res { + Ok(Ok(inner)) => { let _ = tx.send(Ok(inner)); } + Ok(Err(e)) => { let _ = tx.send(Err(format!("open error: {:?}", e))); } + Err(p) => { + let msg = if let Some(s) = p.downcast_ref::<&str>() { s.to_string() } + else if let Some(s) = p.downcast_ref::() { s.clone() } + else { "panic during open".to_string() }; + let _ = tx.send(Err(format!("panic: {}", msg))); + } + } + }); + + let got = match thr.join() { + Ok(_) => rx.recv(), + Err(_) => Err(std::sync::mpsc::RecvError), + }; + + match got { + Ok(Ok(inner)) => { + let boxed = Box::new(tims_dataset { inner }); + unsafe { *out_handle = Box::into_raw(boxed); } + if let Ok(mut g) = LAST_ERROR.lock() { *g = None; } + TimsFfiStatus::Ok + } + Ok(Err(msg)) => { + if let Ok(mut g) = LAST_ERROR.lock() { *g = Some(msg); } + TimsFfiStatus::OpenFailed + } + Err(_) => { + if let Ok(mut g) = LAST_ERROR.lock() { *g = Some("open join/recv failed".to_string()); } + TimsFfiStatus::OpenFailed + } + } +} diff --git a/src/types.rs b/src/types.rs index 83a4f5a..5c77798 100644 --- a/src/types.rs +++ b/src/types.rs @@ -11,6 +11,13 @@ pub struct TimsFfiSpectrum { pub mz: *const c_float, pub intensity: *const c_float, pub im: c_double, + // New fields for Sage parity + pub index: c_uint, // Spectrum.index from SpectrumReader + pub isolation_width: c_double, // isolation window width (0.0 if N/A) + pub isolation_mz: c_double, // isolation window center m/z (0.0 if N/A) + pub charge: c_uchar, // precursor charge (0 = unknown) + pub precursor_intensity: c_double, // precursor intensity (f64::NAN = unknown) + pub frame_index: c_uint, // precursor frame index (u32::MAX = N/A, i.e. MS1) } #[repr(C)] @@ -93,3 +100,16 @@ pub struct TimsFfiFileInfo { pub wall_ms: c_double, // wall time to collect stats (ms) } +#[repr(C)] +#[derive(Clone, Copy)] +pub struct TimsFfiFrame { + pub index: c_uint, + pub rt_seconds: c_double, + pub ms_level: c_uchar, // 1=MS1, 2=MS2, 0=Unknown + pub num_scans: c_uint, + pub num_peaks: c_uint, // total peaks (length of tof_indices & intensities) + pub tof_indices: *const u32, // raw TOF indices, flat array + pub intensities: *const u32, // raw intensities, flat array + pub scan_offsets: *const u64, // per-scan offsets (length: num_scans + 1) +} + diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..fe72cbd --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,226 @@ +// tests/common/mod.rs +// +// Shared helpers for FFI integration tests. +// Uses extern "C" declarations to test the actual C ABI surface. +// +// The `extern crate` forces the linker to include the library's +// #[no_mangle] symbols so that our extern "C" declarations resolve. + +#![allow(dead_code)] + +extern crate timsrust_cpp_bridge; + +use std::ffi::CString; +use std::ptr; + +// ---- FFI function declarations (matches include/timsrust_cpp_bridge.h) ---- + +extern "C" { + pub fn tims_open(path: *const libc::c_char, out: *mut *mut libc::c_void) -> i32; + pub fn tims_close(handle: *mut libc::c_void); + pub fn tims_open_with_config( + path: *const libc::c_char, + cfg: *const libc::c_void, + out: *mut *mut libc::c_void, + ) -> i32; + pub fn tims_num_spectra(handle: *const libc::c_void) -> u32; + pub fn tims_num_frames(handle: *const libc::c_void) -> u32; + pub fn tims_get_spectrum( + handle: *mut libc::c_void, + index: u32, + out_spec: *mut TimsFfiSpectrum, + ) -> i32; + pub fn tims_get_spectra_by_rt( + handle: *mut libc::c_void, + rt_seconds: f64, + n_spectra: i32, + drift_start: f64, + drift_end: f64, + out_count: *mut u32, + out_specs: *mut *mut TimsFfiSpectrum, + ) -> i32; + pub fn tims_free_spectrum_array( + handle: *mut libc::c_void, + specs: *mut TimsFfiSpectrum, + count: u32, + ); + pub fn tims_get_frame( + handle: *mut libc::c_void, + index: u32, + out_frame: *mut TimsFfiFrame, + ) -> i32; + pub fn tims_get_frames_by_level( + handle: *mut libc::c_void, + ms_level: u8, + out_count: *mut u32, + out_frames: *mut *mut TimsFfiFrame, + ) -> i32; + pub fn tims_free_frame_array( + handle: *mut libc::c_void, + frames: *mut TimsFfiFrame, + count: u32, + ); + pub fn tims_get_swath_windows( + handle: *mut libc::c_void, + out_count: *mut u32, + out_windows: *mut *mut TimsFfiSwathWindow, + ) -> i32; + pub fn tims_free_swath_windows( + handle: *mut libc::c_void, + windows: *mut TimsFfiSwathWindow, + ); + pub fn tims_get_last_error( + handle: *mut libc::c_void, + buf: *mut libc::c_char, + buf_len: u32, + ) -> i32; + pub fn tims_file_info( + handle: *mut libc::c_void, + out: *mut TimsFfiFileInfo, + ) -> i32; + pub fn tims_convert_tof_to_mz(handle: *const libc::c_void, tof_index: u32) -> f64; + pub fn tims_convert_scan_to_im(handle: *const libc::c_void, scan_index: u32) -> f64; + pub fn tims_convert_tof_to_mz_array( + handle: *const libc::c_void, + tof_indices: *const u32, + count: u32, + out_mz: *mut f64, + ) -> i32; + pub fn tims_convert_scan_to_im_array( + handle: *const libc::c_void, + scan_indices: *const u32, + count: u32, + out_im: *mut f64, + ) -> i32; + pub fn tims_config_create() -> *mut libc::c_void; + pub fn tims_config_free(cfg: *mut libc::c_void); + pub fn tims_config_set_smoothing_window(cfg: *mut libc::c_void, window: u32); + pub fn tims_config_set_centroiding_window(cfg: *mut libc::c_void, window: u32); + pub fn tims_config_set_calibration_tolerance(cfg: *mut libc::c_void, tolerance: f64); + pub fn tims_config_set_calibrate(cfg: *mut libc::c_void, enabled: u8); +} + +// ---- C-compatible struct mirrors (must match #[repr(C)] in src/types.rs) ---- + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct TimsFfiSpectrum { + pub rt_seconds: f64, + pub precursor_mz: f64, + pub ms_level: u8, + pub num_peaks: u32, + pub mz: *const f32, + pub intensity: *const f32, + pub im: f64, + pub index: u32, + pub isolation_width: f64, + pub isolation_mz: f64, + pub charge: u8, + pub precursor_intensity: f64, + pub frame_index: u32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct TimsFfiSwathWindow { + pub mz_lower: f64, + pub mz_upper: f64, + pub mz_center: f64, + pub im_lower: f64, + pub im_upper: f64, + pub is_ms1: u8, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct TimsFfiLevelStats { + pub count: u32, + pub total_peaks: u64, + pub rt_min: f64, + pub rt_max: f64, + pub mz_min: f64, + pub mz_max: f64, + pub im_min: f64, + pub im_max: f64, + pub intensity_min: f64, + pub intensity_max: f64, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct TimsFfiFileInfo { + pub num_frames: u32, + pub num_spectra_ms2: u32, + pub total_peaks: u64, + pub ms1: TimsFfiLevelStats, + pub ms2: TimsFfiLevelStats, + pub wall_ms: f64, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct TimsFfiFrame { + pub index: u32, + pub rt_seconds: f64, + pub ms_level: u8, + pub num_scans: u32, + pub num_peaks: u32, + pub tof_indices: *const u32, + pub intensities: *const u32, + pub scan_offsets: *const u64, +} + +// ---- Status codes (must match TimsFfiStatus enum in src/types.rs) ---- +// Note: We use i32 constants rather than a Rust enum because the FFI +// functions are declared with `-> i32` to match the C ABI (the C enum +// `timsffi_status` is transmitted as a plain `int`). This mirrors how +// a real C/C++ consumer would interpret the return values. + +pub const TIMSFFI_OK: i32 = 0; +pub const TIMSFFI_ERR_INVALID_UTF8: i32 = 1; +pub const TIMSFFI_ERR_OPEN_FAILED: i32 = 2; +pub const TIMSFFI_ERR_INDEX_OOB: i32 = 3; +pub const TIMSFFI_ERR_INTERNAL: i32 = 255; + +// ---- Helpers ---- + +/// Open a dataset against the stub build. Creates a temp directory +/// that exists on disk (stub open succeeds for any existing path). +pub fn open_stub() -> *mut libc::c_void { + let dir = std::env::temp_dir().join("timsrust_ffi_test_stub"); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let c_path = CString::new(dir.to_str().unwrap()).unwrap(); + let mut handle: *mut libc::c_void = ptr::null_mut(); + let status = unsafe { tims_open(c_path.as_ptr(), &mut handle) }; + assert_eq!(status, TIMSFFI_OK, "open_stub failed with status {status}"); + assert!(!handle.is_null()); + handle +} + +/// Assert that a status code matches expected. +pub fn assert_status(actual: i32, expected: i32) { + assert_eq!( + actual, expected, + "expected status {expected}, got {actual}" + ); +} + +/// Returns DDA dataset path from env, or None. +pub fn dda_path() -> Option { + std::env::var("TIMSRUST_TEST_DATA_DDA").ok() +} + +/// Returns DIA dataset path from env, or None. +pub fn dia_path() -> Option { + std::env::var("TIMSRUST_TEST_DATA_DIA").ok() +} + +/// Open a real dataset by path. Panics on failure. +pub fn open_real(path: &str) -> *mut libc::c_void { + let c_path = CString::new(path).unwrap(); + let mut handle: *mut libc::c_void = ptr::null_mut(); + let status = unsafe { tims_open(c_path.as_ptr(), &mut handle) }; + assert_eq!(status, TIMSFFI_OK, "open_real failed for {path}"); + assert!(!handle.is_null()); + handle +} diff --git a/tests/ffi_converters.rs b/tests/ffi_converters.rs new file mode 100644 index 0000000..5509dd2 --- /dev/null +++ b/tests/ffi_converters.rs @@ -0,0 +1,219 @@ +// tests/ffi_converters.rs +// +// Tests for scalar and array index converters: +// tims_convert_tof_to_mz, tims_convert_scan_to_im, +// tims_convert_tof_to_mz_array, tims_convert_scan_to_im_array. +// +// Tests that call open_stub() are gated to stub-only builds. + +mod common; +use common::*; +use std::ptr; + +// ============================================================ +// Scalar converters — null handle (universal) +// ============================================================ + +#[test] +fn tof_to_mz_null_handle_returns_nan() { + let result = unsafe { tims_convert_tof_to_mz(ptr::null(), 100) }; + assert!(result.is_nan(), "null handle should return NaN"); +} + +#[test] +fn scan_to_im_null_handle_returns_nan() { + let result = unsafe { tims_convert_scan_to_im(ptr::null(), 100) }; + assert!(result.is_nan(), "null handle should return NaN"); +} + +// ============================================================ +// Scalar converters — stub identity (stub-only) +// ============================================================ + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn tof_to_mz_stub_returns_identity() { + let handle = open_stub(); + let result = unsafe { tims_convert_tof_to_mz(handle as *const _, 42) }; + assert_eq!(result, 42.0, "stub should return identity (42.0)"); + unsafe { tims_close(handle) }; +} + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn scan_to_im_stub_returns_identity() { + let handle = open_stub(); + let result = unsafe { tims_convert_scan_to_im(handle as *const _, 99) }; + assert_eq!(result, 99.0, "stub should return identity (99.0)"); + unsafe { tims_close(handle) }; +} + +// ============================================================ +// tims_convert_tof_to_mz_array — null checks (universal) +// ============================================================ + +#[test] +fn tof_to_mz_array_null_handle_returns_internal() { + let input: [u32; 1] = [10]; + let mut output: [f64; 1] = [0.0]; + let status = unsafe { + tims_convert_tof_to_mz_array(ptr::null(), input.as_ptr(), 1, output.as_mut_ptr()) + }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +// ============================================================ +// tims_convert_tof_to_mz_array — stub-only +// ============================================================ + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn tof_to_mz_array_null_input_returns_internal() { + let handle = open_stub(); + let mut output: [f64; 1] = [0.0]; + let status = unsafe { + tims_convert_tof_to_mz_array(handle as *const _, ptr::null(), 1, output.as_mut_ptr()) + }; + assert_status(status, TIMSFFI_ERR_INTERNAL); + unsafe { tims_close(handle) }; +} + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn tof_to_mz_array_null_output_returns_internal() { + let handle = open_stub(); + let input: [u32; 1] = [10]; + let status = unsafe { + tims_convert_tof_to_mz_array(handle as *const _, input.as_ptr(), 1, ptr::null_mut()) + }; + assert_status(status, TIMSFFI_ERR_INTERNAL); + unsafe { tims_close(handle) }; +} + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn tof_to_mz_array_count_zero_returns_ok() { + let handle = open_stub(); + let input: [u32; 1] = [10]; + let mut output: [f64; 1] = [0.0]; + let status = unsafe { + tims_convert_tof_to_mz_array(handle as *const _, input.as_ptr(), 0, output.as_mut_ptr()) + }; + assert_status(status, TIMSFFI_OK); + assert_eq!(output[0], 0.0, "output should be untouched when count=0"); + unsafe { tims_close(handle) }; +} + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn tof_to_mz_array_stub_matches_scalar() { + let handle = open_stub(); + let input: [u32; 3] = [10, 200, 5000]; + let mut output: [f64; 3] = [0.0; 3]; + + let status = unsafe { + tims_convert_tof_to_mz_array( + handle as *const _, + input.as_ptr(), + input.len() as u32, + output.as_mut_ptr(), + ) + }; + assert_status(status, TIMSFFI_OK); + + for (i, &tof) in input.iter().enumerate() { + let scalar = unsafe { tims_convert_tof_to_mz(handle as *const _, tof) }; + assert_eq!( + output[i], scalar, + "array[{i}] ({}) should match scalar ({scalar}) for tof={tof}", + output[i], + ); + } + + unsafe { tims_close(handle) }; +} + +// ============================================================ +// tims_convert_scan_to_im_array — null checks (universal) +// ============================================================ + +#[test] +fn scan_to_im_array_null_handle_returns_internal() { + let input: [u32; 1] = [10]; + let mut output: [f64; 1] = [0.0]; + let status = unsafe { + tims_convert_scan_to_im_array(ptr::null(), input.as_ptr(), 1, output.as_mut_ptr()) + }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +// ============================================================ +// tims_convert_scan_to_im_array — stub-only +// ============================================================ + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn scan_to_im_array_null_input_returns_internal() { + let handle = open_stub(); + let mut output: [f64; 1] = [0.0]; + let status = unsafe { + tims_convert_scan_to_im_array(handle as *const _, ptr::null(), 1, output.as_mut_ptr()) + }; + assert_status(status, TIMSFFI_ERR_INTERNAL); + unsafe { tims_close(handle) }; +} + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn scan_to_im_array_null_output_returns_internal() { + let handle = open_stub(); + let input: [u32; 1] = [10]; + let status = unsafe { + tims_convert_scan_to_im_array(handle as *const _, input.as_ptr(), 1, ptr::null_mut()) + }; + assert_status(status, TIMSFFI_ERR_INTERNAL); + unsafe { tims_close(handle) }; +} + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn scan_to_im_array_count_zero_returns_ok() { + let handle = open_stub(); + let input: [u32; 1] = [10]; + let mut output: [f64; 1] = [0.0]; + let status = unsafe { + tims_convert_scan_to_im_array(handle as *const _, input.as_ptr(), 0, output.as_mut_ptr()) + }; + assert_status(status, TIMSFFI_OK); + assert_eq!(output[0], 0.0, "output should be untouched when count=0"); + unsafe { tims_close(handle) }; +} + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn scan_to_im_array_stub_matches_scalar() { + let handle = open_stub(); + let input: [u32; 3] = [10, 200, 5000]; + let mut output: [f64; 3] = [0.0; 3]; + + let status = unsafe { + tims_convert_scan_to_im_array( + handle as *const _, + input.as_ptr(), + input.len() as u32, + output.as_mut_ptr(), + ) + }; + assert_status(status, TIMSFFI_OK); + + for (i, &scan) in input.iter().enumerate() { + let scalar = unsafe { tims_convert_scan_to_im(handle as *const _, scan) }; + assert_eq!( + output[i], scalar, + "array[{i}] ({}) should match scalar ({scalar}) for scan={scan}", + output[i], + ); + } + + unsafe { tims_close(handle) }; +} diff --git a/tests/ffi_error_handling.rs b/tests/ffi_error_handling.rs new file mode 100644 index 0000000..c46d189 --- /dev/null +++ b/tests/ffi_error_handling.rs @@ -0,0 +1,167 @@ +// tests/ffi_error_handling.rs +// +// Tests for tims_get_last_error: global vs per-handle error retrieval, +// null/zero-length buffer edge cases, truncation, and message content. +// +// Tests that read global error state (null handle) share a process-wide +// Mutex to prevent races when the test runner executes in parallel. + +mod common; +use common::*; +use std::ffi::CString; +use std::ptr; +use std::sync::Mutex; + +/// Serialises tests that depend on the global LAST_ERROR state. +static GLOBAL_ERROR_LOCK: Mutex<()> = Mutex::new(()); + +// ============================================================ +// 1. Global error via null handle +// ============================================================ + +#[test] +fn get_last_error_null_handle_reads_global() { + let _lock = GLOBAL_ERROR_LOCK.lock().unwrap(); + + // Trigger a global error by opening a nonexistent path. + let bad = CString::new("/tmp/timsrust_ffi_test_does_not_exist_err1").unwrap(); + let mut handle: *mut libc::c_void = ptr::null_mut(); + let status = unsafe { tims_open(bad.as_ptr(), &mut handle) }; + assert_status(status, TIMSFFI_ERR_OPEN_FAILED); + + // Read the global error (null handle → global). + let mut buf = [0 as libc::c_char; 256]; + let st = unsafe { tims_get_last_error(ptr::null_mut(), buf.as_mut_ptr(), buf.len() as u32) }; + assert_status(st, TIMSFFI_OK); + + let msg = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr() as *const _) }; + let msg_str = msg.to_str().expect("valid utf-8"); + assert!(!msg_str.is_empty(), "global error message should be non-empty"); +} + +// ============================================================ +// 2. Per-handle error via valid handle (stub-only) +// ============================================================ + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn get_last_error_with_valid_handle_reads_per_handle() { + let handle = open_stub(); + + // tims_get_frame with index 0 on stub (0 frames) → INDEX_OOB and sets + // per-handle last_error. + let mut frame = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_frame(handle, 0, frame.as_mut_ptr()) }; + assert_status(status, TIMSFFI_ERR_INDEX_OOB); + + // Read per-handle error. + let mut buf = [0 as libc::c_char; 256]; + let st = unsafe { tims_get_last_error(handle, buf.as_mut_ptr(), buf.len() as u32) }; + assert_status(st, TIMSFFI_OK); + + let msg = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr() as *const _) }; + let msg_str = msg.to_str().expect("valid utf-8"); + assert!(!msg_str.is_empty(), "per-handle error message should be non-empty"); + + unsafe { tims_close(handle) }; +} + +// ============================================================ +// 3. Null buffer pointer → INTERNAL +// ============================================================ + +#[test] +fn get_last_error_null_buffer_returns_internal() { + let st = unsafe { tims_get_last_error(ptr::null_mut(), ptr::null_mut(), 128) }; + assert_status(st, TIMSFFI_ERR_INTERNAL); +} + +// ============================================================ +// 4. Zero-length buffer → INTERNAL +// ============================================================ + +#[test] +fn get_last_error_zero_length_buffer_returns_internal() { + let mut buf = [0 as libc::c_char; 1]; + let st = unsafe { tims_get_last_error(ptr::null_mut(), buf.as_mut_ptr(), 0) }; + assert_status(st, TIMSFFI_ERR_INTERNAL); +} + +// ============================================================ +// 5. Truncation into a 5-byte buffer +// ============================================================ + +#[test] +fn get_last_error_truncation() { + let _lock = GLOBAL_ERROR_LOCK.lock().unwrap(); + + // Trigger a global error (message will be longer than 4 chars). + let bad = CString::new("/tmp/timsrust_ffi_test_does_not_exist_err5").unwrap(); + let mut handle: *mut libc::c_void = ptr::null_mut(); + let status = unsafe { tims_open(bad.as_ptr(), &mut handle) }; + assert_status(status, TIMSFFI_ERR_OPEN_FAILED); + + // Read into a 5-byte buffer → 4 chars + null terminator. + let mut buf = [0 as libc::c_char; 5]; + let st = unsafe { tims_get_last_error(ptr::null_mut(), buf.as_mut_ptr(), 5) }; + assert_status(st, TIMSFFI_OK); + + let msg = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr() as *const _) }; + let msg_str = msg.to_str().expect("valid utf-8"); + assert_eq!(msg_str.len(), 4, "truncated message should be exactly 4 chars, got '{}'", msg_str); + // Ensure the null terminator is in the right place. + assert_eq!(buf[4], 0, "5th byte should be null terminator"); +} + +// ============================================================ +// 6. After successful open, global error is cleared (stub-only) +// ============================================================ + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn get_last_error_after_success_is_empty() { + let _lock = GLOBAL_ERROR_LOCK.lock().unwrap(); + + // open_stub() succeeds, which clears the global error. + let handle = open_stub(); + + // Read global error (null handle). + let mut buf = [0 as libc::c_char; 256]; + let st = unsafe { tims_get_last_error(ptr::null_mut(), buf.as_mut_ptr(), buf.len() as u32) }; + assert_status(st, TIMSFFI_OK); + + let msg = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr() as *const _) }; + let msg_str = msg.to_str().expect("valid utf-8"); + assert!(msg_str.is_empty(), "global error should be empty after successful open, got '{}'", msg_str); + + unsafe { tims_close(handle) }; +} + +// ============================================================ +// 7. Error message content is meaningful +// ============================================================ + +#[test] +fn error_message_content_meaningful() { + let _lock = GLOBAL_ERROR_LOCK.lock().unwrap(); + + let bad = CString::new("/tmp/timsrust_ffi_test_does_not_exist_err7").unwrap(); + let mut handle: *mut libc::c_void = ptr::null_mut(); + let status = unsafe { tims_open(bad.as_ptr(), &mut handle) }; + assert_status(status, TIMSFFI_ERR_OPEN_FAILED); + + let mut buf = [0 as libc::c_char; 512]; + let st = unsafe { tims_get_last_error(ptr::null_mut(), buf.as_mut_ptr(), buf.len() as u32) }; + assert_status(st, TIMSFFI_OK); + + let msg = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr() as *const _) }; + let msg_str = msg.to_str().expect("valid utf-8").to_lowercase(); + let has_keyword = msg_str.contains("path") + || msg_str.contains("not found") + || msg_str.contains("error"); + assert!( + has_keyword, + "error message should contain 'path', 'not found', or 'error', got: '{}'", + msg_str, + ); +} diff --git a/tests/ffi_frame.rs b/tests/ffi_frame.rs new file mode 100644 index 0000000..9a2f228 --- /dev/null +++ b/tests/ffi_frame.rs @@ -0,0 +1,100 @@ +// tests/ffi_frame.rs +// +// Tests for tims_get_frame, tims_get_frames_by_level, and +// tims_free_frame_array: null-handle guards, index-OOB on stub, +// batch edge cases, and free safety. +// +// Tests that call open_stub() are gated to stub-only builds. + +mod common; +use common::*; +use std::ptr; + +// ============================================================ +// Single frame tests — universal +// ============================================================ + +#[test] +fn get_frame_null_handle_returns_internal() { + let mut frame = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_frame(ptr::null_mut(), 0, frame.as_mut_ptr()) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +// ============================================================ +// Single frame tests — stub-only +// ============================================================ + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn get_frame_index_0_stub_returns_oob() { + let handle = open_stub(); + let mut frame = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_frame(handle, 0, frame.as_mut_ptr()) }; + assert_status(status, TIMSFFI_ERR_INDEX_OOB); + unsafe { tims_close(handle) }; +} + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn get_frame_null_out_returns_internal() { + let handle = open_stub(); + let status = unsafe { tims_get_frame(handle, 0, ptr::null_mut()) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); + unsafe { tims_close(handle) }; +} + +// ============================================================ +// Batch frame tests — universal +// ============================================================ + +#[test] +fn get_frames_by_level_null_handle_returns_internal() { + let mut count: u32 = 0; + let mut frames: *mut TimsFfiFrame = ptr::null_mut(); + let status = unsafe { tims_get_frames_by_level(ptr::null_mut(), 1, &mut count, &mut frames) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +// ============================================================ +// Batch frame tests — stub-only +// ============================================================ + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn get_frames_by_level_stub_returns_empty() { + let handle = open_stub(); + let mut count: u32 = 99; + let mut frames: *mut TimsFfiFrame = ptr::null_mut(); + let status = unsafe { tims_get_frames_by_level(handle, 1, &mut count, &mut frames) }; + assert_status(status, TIMSFFI_OK); + assert_eq!(count, 0, "stub should return 0 frames"); + assert!(frames.is_null(), "frames pointer should be null when count is 0"); + unsafe { tims_close(handle) }; +} + +// ============================================================ +// Free safety — stub-only +// ============================================================ + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn free_frame_array_null_no_crash() { + let handle = open_stub(); + unsafe { tims_free_frame_array(handle, ptr::null_mut(), 0) }; + unsafe { tims_close(handle) }; +} + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn free_frame_array_non_null_count_zero() { + let handle = open_stub(); + // Allocate a small buffer via libc::malloc, pass count=0 so no + // per-element iteration happens — only the outer array is freed. + let buf = unsafe { + libc::malloc(std::mem::size_of::()) as *mut TimsFfiFrame + }; + assert!(!buf.is_null(), "malloc should succeed"); + unsafe { tims_free_frame_array(handle, buf, 0) }; + unsafe { tims_close(handle) }; +} diff --git a/tests/ffi_lifecycle.rs b/tests/ffi_lifecycle.rs new file mode 100644 index 0000000..14132e4 --- /dev/null +++ b/tests/ffi_lifecycle.rs @@ -0,0 +1,189 @@ +// tests/ffi_lifecycle.rs +// +// Lifecycle tests: tims_open / tims_close, open_with_config, config builder. +// Tests that call open_stub() are gated to stub-only builds because the +// real timsrust reader cannot open a fake temp directory. + +mod common; +use common::*; +use std::ffi::CString; +use std::ptr; + +// ============================================================ +// tims_open / tims_close tests (universal — no handle needed) +// ============================================================ + +#[test] +fn open_null_path_returns_internal() { + let mut handle: *mut libc::c_void = ptr::null_mut(); + let status = unsafe { tims_open(ptr::null(), &mut handle) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +#[test] +fn open_null_out_handle_returns_internal() { + let c_path = CString::new("/tmp/whatever").unwrap(); + let status = unsafe { tims_open(c_path.as_ptr(), ptr::null_mut()) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +#[test] +fn open_nonexistent_path_returns_open_failed() { + let c_path = CString::new("/tmp/timsrust_ffi_test_nonexistent_path_xyz").unwrap(); + let mut handle: *mut libc::c_void = ptr::null_mut(); + let status = unsafe { tims_open(c_path.as_ptr(), &mut handle) }; + assert_status(status, TIMSFFI_ERR_OPEN_FAILED); +} + +#[test] +fn close_null_handle_no_crash() { + unsafe { tims_close(ptr::null_mut()) }; +} + +// ============================================================ +// tims_open / tims_close tests (stub-only — needs fake path) +// ============================================================ + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn open_valid_stub_succeeds() { + let handle = open_stub(); + assert!(!handle.is_null()); + unsafe { tims_close(handle) }; +} + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn close_valid_handle_no_crash() { + let handle = open_stub(); + unsafe { tims_close(handle) }; +} + +// ============================================================ +// open_with_config error paths (universal — expect failure) +// ============================================================ + +#[test] +fn open_with_config_null_path_returns_internal() { + let cfg = unsafe { tims_config_create() }; + assert!(!cfg.is_null()); + let mut handle: *mut libc::c_void = ptr::null_mut(); + let status = unsafe { tims_open_with_config(ptr::null(), cfg, &mut handle) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); + unsafe { tims_config_free(cfg) }; +} + +#[test] +fn open_with_config_null_config_returns_internal() { + let c_path = CString::new("/tmp/whatever").unwrap(); + let mut handle: *mut libc::c_void = ptr::null_mut(); + let status = unsafe { tims_open_with_config(c_path.as_ptr(), ptr::null(), &mut handle) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +#[test] +fn open_with_config_null_out_handle_returns_internal() { + let c_path = CString::new("/tmp/whatever").unwrap(); + let cfg = unsafe { tims_config_create() }; + assert!(!cfg.is_null()); + let status = unsafe { tims_open_with_config(c_path.as_ptr(), cfg, ptr::null_mut()) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); + unsafe { tims_config_free(cfg) }; +} + +#[test] +fn open_with_config_nonexistent_path_returns_open_failed() { + let c_path = CString::new("/tmp/timsrust_ffi_test_nonexistent_path_xyz").unwrap(); + let cfg = unsafe { tims_config_create() }; + assert!(!cfg.is_null()); + let mut handle: *mut libc::c_void = ptr::null_mut(); + let status = unsafe { tims_open_with_config(c_path.as_ptr(), cfg, &mut handle) }; + assert_status(status, TIMSFFI_ERR_OPEN_FAILED); + unsafe { tims_config_free(cfg) }; +} + +// ============================================================ +// open_with_config happy path (stub-only) +// ============================================================ + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn open_with_config_happy_path() { + let dir = std::env::temp_dir().join("timsrust_ffi_test_config_happy"); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let c_path = CString::new(dir.to_str().unwrap()).unwrap(); + + let cfg = unsafe { tims_config_create() }; + assert!(!cfg.is_null()); + + let mut handle: *mut libc::c_void = ptr::null_mut(); + let status = unsafe { tims_open_with_config(c_path.as_ptr(), cfg, &mut handle) }; + assert_status(status, TIMSFFI_OK); + assert!(!handle.is_null()); + + unsafe { tims_close(handle) }; + unsafe { tims_config_free(cfg) }; +} + +// ============================================================ +// Config builder (universal — no dataset needed) +// ============================================================ + +#[test] +fn config_create_returns_non_null() { + let cfg = unsafe { tims_config_create() }; + assert!(!cfg.is_null()); + unsafe { tims_config_free(cfg) }; +} + +#[test] +fn config_free_null_no_crash() { + unsafe { tims_config_free(ptr::null_mut()) }; +} + +#[test] +fn config_free_valid_no_crash() { + let cfg = unsafe { tims_config_create() }; + assert!(!cfg.is_null()); + unsafe { tims_config_free(cfg) }; +} + +#[test] +fn config_setters_null_no_crash() { + unsafe { + tims_config_set_smoothing_window(ptr::null_mut(), 5); + tims_config_set_centroiding_window(ptr::null_mut(), 3); + tims_config_set_calibration_tolerance(ptr::null_mut(), 0.01); + tims_config_set_calibrate(ptr::null_mut(), 1); + } +} + +// ============================================================ +// Config builder full lifecycle (stub-only — opens fake path) +// ============================================================ + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn config_builder_full_lifecycle() { + let cfg = unsafe { tims_config_create() }; + assert!(!cfg.is_null()); + + unsafe { + tims_config_set_smoothing_window(cfg, 10); + tims_config_set_centroiding_window(cfg, 5); + tims_config_set_calibration_tolerance(cfg, 0.05); + tims_config_set_calibrate(cfg, 1); + } + + let dir = std::env::temp_dir().join("timsrust_ffi_test_config_full"); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let c_path = CString::new(dir.to_str().unwrap()).unwrap(); + + let mut handle: *mut libc::c_void = ptr::null_mut(); + let status = unsafe { tims_open_with_config(c_path.as_ptr(), cfg, &mut handle) }; + assert_status(status, TIMSFFI_OK); + assert!(!handle.is_null()); + + unsafe { tims_close(handle) }; + unsafe { tims_config_free(cfg) }; +} diff --git a/tests/ffi_query.rs b/tests/ffi_query.rs new file mode 100644 index 0000000..bd73b11 --- /dev/null +++ b/tests/ffi_query.rs @@ -0,0 +1,139 @@ +// tests/ffi_query.rs +// +// Tests for query/metadata functions: tims_num_spectra, tims_num_frames, +// tims_get_swath_windows, tims_free_swath_windows, and tims_file_info. +// +// Tests that call open_stub() are gated to stub-only builds. + +mod common; +use common::*; +use std::ptr; + +// ============================================================ +// tims_num_spectra — universal +// ============================================================ + +#[test] +fn num_spectra_null_handle_returns_zero() { + let n = unsafe { tims_num_spectra(ptr::null()) }; + assert_eq!(n, 0, "null handle should return 0"); +} + +// ============================================================ +// tims_num_spectra — stub-only +// ============================================================ + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn num_spectra_stub_returns_zero() { + let handle = open_stub(); + let n = unsafe { tims_num_spectra(handle as *const _) }; + assert_eq!(n, 0, "stub dataset should have 0 spectra"); + unsafe { tims_close(handle) }; +} + +// ============================================================ +// tims_num_frames — universal +// ============================================================ + +#[test] +fn num_frames_null_handle_returns_zero() { + let n = unsafe { tims_num_frames(ptr::null()) }; + assert_eq!(n, 0, "null handle should return 0"); +} + +// ============================================================ +// tims_num_frames — stub-only +// ============================================================ + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn num_frames_stub_returns_zero() { + let handle = open_stub(); + let n = unsafe { tims_num_frames(handle as *const _) }; + assert_eq!(n, 0, "stub dataset should have 0 frames"); + unsafe { tims_close(handle) }; +} + +// ============================================================ +// tims_get_swath_windows — universal +// ============================================================ + +#[test] +fn get_swath_windows_null_handle_returns_internal() { + let mut count: u32 = 0; + let mut windows: *mut TimsFfiSwathWindow = ptr::null_mut(); + let status = unsafe { tims_get_swath_windows(ptr::null_mut(), &mut count, &mut windows) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +// ============================================================ +// tims_get_swath_windows — stub-only +// ============================================================ + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn get_swath_windows_stub_returns_empty() { + let handle = open_stub(); + let mut count: u32 = 99; + let mut windows: *mut TimsFfiSwathWindow = ptr::null_mut(); + let status = unsafe { tims_get_swath_windows(handle, &mut count, &mut windows) }; + assert_status(status, TIMSFFI_OK); + assert_eq!(count, 0, "stub should have 0 swath windows"); + unsafe { tims_close(handle) }; +} + +// ============================================================ +// tims_free_swath_windows — universal +// ============================================================ + +#[test] +fn free_swath_windows_null_no_crash() { + unsafe { tims_free_swath_windows(ptr::null_mut(), ptr::null_mut()) }; +} + +// ============================================================ +// tims_file_info — universal +// ============================================================ + +#[test] +fn file_info_null_handle_returns_internal() { + let mut info = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_file_info(ptr::null_mut(), info.as_mut_ptr()) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +// ============================================================ +// tims_file_info — stub-only +// ============================================================ + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn file_info_stub_returns_zeros() { + let handle = open_stub(); + let mut info = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_file_info(handle, info.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + let info = unsafe { info.assume_init() }; + + assert_eq!(info.num_frames, 0, "stub num_frames"); + assert_eq!(info.num_spectra_ms2, 0, "stub num_spectra_ms2"); + assert_eq!(info.total_peaks, 0, "stub total_peaks"); + assert_eq!(info.ms1.count, 0, "stub ms1.count"); + assert_eq!(info.ms2.count, 0, "stub ms2.count"); + assert_eq!(info.ms1.rt_min, 0.0, "stub ms1.rt_min"); + assert_eq!(info.ms1.rt_max, 0.0, "stub ms1.rt_max"); + assert_eq!(info.ms2.mz_min, 0.0, "stub ms2.mz_min"); + assert_eq!(info.ms2.mz_max, 0.0, "stub ms2.mz_max"); + + unsafe { tims_close(handle) }; +} + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn file_info_null_out_returns_internal() { + let handle = open_stub(); + let status = unsafe { tims_file_info(handle, ptr::null_mut()) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); + unsafe { tims_close(handle) }; +} diff --git a/tests/ffi_real_data.rs b/tests/ffi_real_data.rs new file mode 100644 index 0000000..56720b0 --- /dev/null +++ b/tests/ffi_real_data.rs @@ -0,0 +1,570 @@ +// tests/ffi_real_data.rs +// +// Real-data integration tests that run against actual Bruker .d datasets. +// Gated behind environment variables: +// TIMSRUST_TEST_DATA_DDA — path to a DDA .d dataset +// TIMSRUST_TEST_DATA_DIA — path to a DIA-PASEF .d dataset +// +// When the corresponding env var is not set, tests print a skip message +// and return without failure. + +mod common; +use common::*; +use std::ptr; + +// ---- Env-var gating macros ---- + +macro_rules! require_dda { + () => { + match dda_path() { + Some(p) => p, + None => { + eprintln!("TIMSRUST_TEST_DATA_DDA not set, skipping"); + return; + } + } + }; +} + +macro_rules! require_dia { + () => { + match dia_path() { + Some(p) => p, + None => { + eprintln!("TIMSRUST_TEST_DATA_DIA not set, skipping"); + return; + } + } + }; +} + +// ============================================================ +// DDA Tests +// ============================================================ + +#[test] +fn dda_open_succeeds() { + let path = require_dda!(); + let handle = open_real(&path); + unsafe { tims_close(handle) }; +} + +#[test] +fn dda_num_spectra_positive() { + let path = require_dda!(); + let handle = open_real(&path); + let n = unsafe { tims_num_spectra(handle as *const _) }; + assert!(n > 0, "DDA dataset should have > 0 spectra, got {n}"); + unsafe { tims_close(handle) }; +} + +#[test] +fn dda_num_frames_positive() { + let path = require_dda!(); + let handle = open_real(&path); + let num_frames = unsafe { tims_num_frames(handle as *const _) }; + let num_spectra = unsafe { tims_num_spectra(handle as *const _) }; + assert!(num_frames > 0, "DDA dataset should have > 0 frames, got {num_frames}"); + assert!( + num_frames <= num_spectra, + "num_frames ({num_frames}) should be <= num_spectra ({num_spectra})" + ); + unsafe { tims_close(handle) }; +} + +#[test] +fn dda_get_spectrum_0() { + let path = require_dda!(); + let handle = open_real(&path); + + let mut spec = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_spectrum(handle, 0, spec.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + let spec = unsafe { spec.assume_init() }; + + assert!(spec.num_peaks > 0, "spectrum 0 should have > 0 peaks, got {}", spec.num_peaks); + assert!(!spec.mz.is_null(), "mz pointer should be non-null"); + assert!(!spec.intensity.is_null(), "intensity pointer should be non-null"); + assert!( + spec.ms_level == 1 || spec.ms_level == 2, + "ms_level should be 1 or 2, got {}", + spec.ms_level + ); + + unsafe { tims_close(handle) }; +} + +#[test] +fn dda_spectrum_mz_sorted() { + let path = require_dda!(); + let handle = open_real(&path); + + let mut spec = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_spectrum(handle, 0, spec.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + let spec = unsafe { spec.assume_init() }; + + let n = spec.num_peaks as usize; + if n > 1 { + let mz_slice = unsafe { std::slice::from_raw_parts(spec.mz, n) }; + for i in 1..n { + assert!( + mz_slice[i] >= mz_slice[i - 1], + "mz values should be sorted: mz[{}]={} < mz[{}]={}", + i, + mz_slice[i], + i - 1, + mz_slice[i - 1] + ); + } + } + + unsafe { tims_close(handle) }; +} + +#[test] +fn dda_spectrum_intensity_non_negative() { + let path = require_dda!(); + let handle = open_real(&path); + + let mut spec = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_spectrum(handle, 0, spec.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + let spec = unsafe { spec.assume_init() }; + + let n = spec.num_peaks as usize; + if n > 0 { + let int_slice = unsafe { std::slice::from_raw_parts(spec.intensity, n) }; + for (i, &val) in int_slice.iter().enumerate() { + assert!( + val >= 0.0, + "intensity[{i}] should be >= 0.0, got {val}" + ); + } + } + + unsafe { tims_close(handle) }; +} + +#[test] +fn dda_spectrum_metadata_fields() { + let path = require_dda!(); + let handle = open_real(&path); + + let num_spectra = unsafe { tims_num_spectra(handle as *const _) }; + let scan_limit = std::cmp::min(num_spectra, 100); + + let mut found_ms2 = false; + for idx in 0..scan_limit { + let mut spec = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_spectrum(handle, idx, spec.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + let spec = unsafe { spec.assume_init() }; + + if spec.ms_level == 2 { + found_ms2 = true; + assert!( + spec.frame_index != u32::MAX, + "MS2 spectrum {idx} should have a valid frame_index, got u32::MAX" + ); + assert!( + spec.isolation_width >= 0.0, + "MS2 spectrum {idx} isolation_width should be >= 0.0, got {}", + spec.isolation_width + ); + assert!( + spec.isolation_mz >= 0.0, + "MS2 spectrum {idx} isolation_mz should be >= 0.0, got {}", + spec.isolation_mz + ); + } + } + + // DDA datasets should contain at least some MS2 spectra in the first 100 + assert!(found_ms2, "expected at least one MS2 spectrum in first {scan_limit} spectra"); + + unsafe { tims_close(handle) }; +} + +#[test] +fn dda_get_frame_0() { + let path = require_dda!(); + let handle = open_real(&path); + + let mut frame = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_frame(handle, 0, frame.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + let frame = unsafe { frame.assume_init() }; + + assert!(frame.num_peaks > 0, "frame 0 should have > 0 peaks, got {}", frame.num_peaks); + assert!(frame.num_scans > 0, "frame 0 should have > 0 scans, got {}", frame.num_scans); + + unsafe { tims_close(handle) }; +} + +#[test] +fn dda_frame_scan_offsets_monotonic() { + let path = require_dda!(); + let handle = open_real(&path); + + let mut frame = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_frame(handle, 0, frame.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + let frame = unsafe { frame.assume_init() }; + + if frame.num_scans > 0 { + let offsets_len = (frame.num_scans + 1) as usize; + let offsets = unsafe { std::slice::from_raw_parts(frame.scan_offsets, offsets_len) }; + + for i in 1..offsets_len { + assert!( + offsets[i] >= offsets[i - 1], + "scan_offsets should be monotonic: offsets[{i}]={} < offsets[{}]={}", + offsets[i], + i - 1, + offsets[i - 1] + ); + } + + assert_eq!( + offsets[offsets_len - 1], + frame.num_peaks as u64, + "last scan_offset ({}) should equal num_peaks ({})", + offsets[offsets_len - 1], + frame.num_peaks + ); + } + + unsafe { tims_close(handle) }; +} + +#[test] +fn dda_get_frames_by_level_ms1() { + let path = require_dda!(); + let handle = open_real(&path); + + let mut count: u32 = 0; + let mut frames: *mut TimsFfiFrame = ptr::null_mut(); + let status = unsafe { tims_get_frames_by_level(handle, 1, &mut count, &mut frames) }; + assert_status(status, TIMSFFI_OK); + assert!(count > 0, "DDA dataset should have > 0 MS1 frames, got {count}"); + assert!(!frames.is_null(), "frames pointer should be non-null"); + + // First frame should be MS1 + let first = unsafe { *frames }; + assert_eq!(first.ms_level, 1, "first MS1 frame should have ms_level=1, got {}", first.ms_level); + + unsafe { tims_free_frame_array(handle, frames, count) }; + unsafe { tims_close(handle) }; +} + +#[test] +fn dda_get_spectra_by_rt() { + let path = require_dda!(); + let handle = open_real(&path); + + // Get file info to determine mid RT + let mut info = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_file_info(handle, info.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + let info = unsafe { info.assume_init() }; + let mid_rt = (info.ms2.rt_min + info.ms2.rt_max) / 2.0; + + // Query spectra near mid RT, no IM filter (wide range) + let mut count: u32 = 0; + let mut specs: *mut TimsFfiSpectrum = ptr::null_mut(); + let status = unsafe { + tims_get_spectra_by_rt(handle, mid_rt, 3, 0.0, 1e15, &mut count, &mut specs) + }; + assert_status(status, TIMSFFI_OK); + assert!(count > 0, "should find > 0 spectra near mid RT ({mid_rt})"); + assert!(!specs.is_null(), "specs pointer should be non-null"); + + // First returned spectrum's RT should be within 60s of requested + let first = unsafe { *specs }; + let rt_diff = (first.rt_seconds - mid_rt).abs(); + assert!( + rt_diff <= 60.0, + "first spectrum RT ({}) should be within 60s of requested RT ({mid_rt}), diff={rt_diff}", + first.rt_seconds + ); + + unsafe { tims_free_spectrum_array(handle, specs, count) }; + unsafe { tims_close(handle) }; +} + +#[test] +fn dda_converters_positive_finite() { + let path = require_dda!(); + let handle = open_real(&path); + + let mz = unsafe { tims_convert_tof_to_mz(handle as *const _, 100) }; + assert!(mz.is_finite(), "tof_to_mz(100) should be finite, got {mz}"); + assert!(mz > 0.0, "tof_to_mz(100) should be positive, got {mz}"); + + let im = unsafe { tims_convert_scan_to_im(handle as *const _, 100) }; + assert!(im.is_finite(), "scan_to_im(100) should be finite, got {im}"); + assert!(im > 0.0, "scan_to_im(100) should be positive, got {im}"); + + unsafe { tims_close(handle) }; +} + +#[test] +fn dda_converter_array_matches_scalar() { + let path = require_dda!(); + let handle = open_real(&path); + + let tof_indices: [u32; 3] = [50, 100, 500]; + let mut mz_out: [f64; 3] = [0.0; 3]; + let status = unsafe { + tims_convert_tof_to_mz_array( + handle as *const _, + tof_indices.as_ptr(), + 3, + mz_out.as_mut_ptr(), + ) + }; + assert_status(status, TIMSFFI_OK); + + for (i, &tof) in tof_indices.iter().enumerate() { + let scalar = unsafe { tims_convert_tof_to_mz(handle as *const _, tof) }; + assert_eq!( + mz_out[i], scalar, + "tof_to_mz array[{i}] ({}) should match scalar ({scalar}) for tof={tof}", + mz_out[i] + ); + } + + let scan_indices: [u32; 3] = [50, 100, 500]; + let mut im_out: [f64; 3] = [0.0; 3]; + let status = unsafe { + tims_convert_scan_to_im_array( + handle as *const _, + scan_indices.as_ptr(), + 3, + im_out.as_mut_ptr(), + ) + }; + assert_status(status, TIMSFFI_OK); + + for (i, &scan) in scan_indices.iter().enumerate() { + let scalar = unsafe { tims_convert_scan_to_im(handle as *const _, scan) }; + assert_eq!( + im_out[i], scalar, + "scan_to_im array[{i}] ({}) should match scalar ({scalar}) for scan={scan}", + im_out[i] + ); + } + + unsafe { tims_close(handle) }; +} + +#[test] +fn dda_file_info_populated() { + let path = require_dda!(); + let handle = open_real(&path); + + let mut info = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_file_info(handle, info.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + let info = unsafe { info.assume_init() }; + + assert!(info.total_peaks > 0, "total_peaks should be > 0, got {}", info.total_peaks); + assert!( + info.ms2.rt_min < info.ms2.rt_max, + "ms2.rt_min ({}) should be < ms2.rt_max ({})", + info.ms2.rt_min, + info.ms2.rt_max + ); + assert!( + info.ms2.mz_min > 0.0, + "ms2.mz_min should be > 0.0, got {}", + info.ms2.mz_min + ); + + unsafe { tims_close(handle) }; +} + +#[test] +fn dda_swath_windows() { + let path = require_dda!(); + let handle = open_real(&path); + + let mut count: u32 = 0; + let mut windows: *mut TimsFfiSwathWindow = ptr::null_mut(); + let status = unsafe { tims_get_swath_windows(handle, &mut count, &mut windows) }; + assert_status(status, TIMSFFI_OK); + + // DDA datasets may have 0 swath windows — that is acceptable + if count > 0 { + assert!(!windows.is_null(), "windows pointer should be non-null when count > 0"); + unsafe { tims_free_swath_windows(handle, windows) }; + } + + unsafe { tims_close(handle) }; +} + +// ============================================================ +// DIA Tests +// ============================================================ + +#[test] +fn dia_open_succeeds() { + let path = require_dia!(); + let handle = open_real(&path); + unsafe { tims_close(handle) }; +} + +#[test] +fn dia_spectra_much_more_than_frames() { + let path = require_dia!(); + let handle = open_real(&path); + + let num_spectra = unsafe { tims_num_spectra(handle as *const _) }; + let num_frames = unsafe { tims_num_frames(handle as *const _) }; + + assert!( + num_spectra > num_frames * 2, + "DIA dataset: num_spectra ({num_spectra}) should be > 2 * num_frames ({num_frames})" + ); + + unsafe { tims_close(handle) }; +} + +#[test] +fn dia_get_spectra_by_rt_with_im_filter() { + let path = require_dia!(); + let handle = open_real(&path); + + // Get file info to determine mid RT and mid IM + let mut info = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_file_info(handle, info.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + let info = unsafe { info.assume_init() }; + + let mid_rt = (info.ms2.rt_min + info.ms2.rt_max) / 2.0; + let mid_im = (info.ms2.im_min + info.ms2.im_max) / 2.0; + let im_lo = mid_im - 0.05; + let im_hi = mid_im + 0.05; + + let mut count: u32 = 0; + let mut specs: *mut TimsFfiSpectrum = ptr::null_mut(); + let status = unsafe { + tims_get_spectra_by_rt(handle, mid_rt, 10, im_lo, im_hi, &mut count, &mut specs) + }; + assert_status(status, TIMSFFI_OK); + + if count > 0 { + assert!(!specs.is_null(), "specs pointer should be non-null when count > 0"); + let spec_slice = unsafe { std::slice::from_raw_parts(specs, count as usize) }; + let tolerance = 0.01; + for (i, sp) in spec_slice.iter().enumerate() { + assert!( + sp.im >= im_lo - tolerance && sp.im <= im_hi + tolerance, + "spectrum {i} IM ({}) should be within [{}, {}] (tolerance {tolerance})", + sp.im, + im_lo, + im_hi + ); + } + unsafe { tims_free_spectrum_array(handle, specs, count) }; + } + + unsafe { tims_close(handle) }; +} + +#[test] +fn dia_swath_windows_populated() { + let path = require_dia!(); + let handle = open_real(&path); + + let mut count: u32 = 0; + let mut windows: *mut TimsFfiSwathWindow = ptr::null_mut(); + let status = unsafe { tims_get_swath_windows(handle, &mut count, &mut windows) }; + assert_status(status, TIMSFFI_OK); + + assert!(count > 0, "DIA dataset should have > 0 swath windows, got {count}"); + assert!(!windows.is_null(), "windows pointer should be non-null"); + + let win_slice = unsafe { std::slice::from_raw_parts(windows, count as usize) }; + + let mut min_mz = f64::INFINITY; + let mut max_mz = f64::NEG_INFINITY; + + for (i, w) in win_slice.iter().enumerate() { + assert!( + w.mz_lower < w.mz_upper, + "window {i}: mz_lower ({}) should be < mz_upper ({})", + w.mz_lower, + w.mz_upper + ); + assert!( + w.im_lower < w.im_upper, + "window {i}: im_lower ({}) should be < im_upper ({})", + w.im_lower, + w.im_upper + ); + if w.mz_lower < min_mz { min_mz = w.mz_lower; } + if w.mz_upper > max_mz { max_mz = w.mz_upper; } + } + + let coverage = max_mz - min_mz; + assert!( + coverage > 100.0, + "swath window m/z coverage ({coverage}) should span > 100 Da (min={min_mz}, max={max_mz})" + ); + + unsafe { tims_free_swath_windows(handle, windows) }; + unsafe { tims_close(handle) }; +} + +// ============================================================ +// Cross-cutting Tests +// ============================================================ + +#[test] +fn reopen_after_close() { + let path = require_dda!(); + + // First open + let handle1 = open_real(&path); + let count1 = unsafe { tims_num_spectra(handle1 as *const _) }; + unsafe { tims_close(handle1) }; + + // Reopen + let handle2 = open_real(&path); + let count2 = unsafe { tims_num_spectra(handle2 as *const _) }; + unsafe { tims_close(handle2) }; + + assert_eq!( + count1, count2, + "spectrum count should be the same after reopen: first={count1}, second={count2}" + ); +} + +#[test] +fn two_handles_simultaneously() { + let dda = require_dda!(); + let dia = require_dia!(); + + let h_dda = open_real(&dda); + let h_dia = open_real(&dia); + + let n_dda = unsafe { tims_num_spectra(h_dda as *const _) }; + let n_dia = unsafe { tims_num_spectra(h_dia as *const _) }; + + assert!(n_dda > 0, "DDA handle should have > 0 spectra while DIA is also open"); + assert!(n_dia > 0, "DIA handle should have > 0 spectra while DDA is also open"); + + // Query a spectrum from each to verify independence + let mut spec_dda = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_spectrum(h_dda, 0, spec_dda.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + + let mut spec_dia = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_spectrum(h_dia, 0, spec_dia.as_mut_ptr()) }; + assert_status(status, TIMSFFI_OK); + + unsafe { tims_close(h_dda) }; + unsafe { tims_close(h_dia) }; +} diff --git a/tests/ffi_spectrum.rs b/tests/ffi_spectrum.rs new file mode 100644 index 0000000..739e5f4 --- /dev/null +++ b/tests/ffi_spectrum.rs @@ -0,0 +1,132 @@ +// tests/ffi_spectrum.rs +// +// Tests for tims_get_spectrum, tims_get_spectra_by_rt, and +// tims_free_spectrum_array: null-handle guards, index-OOB on stub, +// batch edge cases, and free safety. +// +// Tests that call open_stub() are gated to stub-only builds. + +mod common; +use common::*; +use std::ptr; + +// ============================================================ +// Single spectrum tests — universal (no handle needed) +// ============================================================ + +#[test] +fn get_spectrum_null_handle_returns_internal() { + let mut spec = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_spectrum(ptr::null_mut(), 0, spec.as_mut_ptr()) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +// ============================================================ +// Single spectrum tests — stub-only +// ============================================================ + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn get_spectrum_index_0_stub_returns_oob() { + let handle = open_stub(); + let mut spec = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { tims_get_spectrum(handle, 0, spec.as_mut_ptr()) }; + assert_status(status, TIMSFFI_ERR_INDEX_OOB); + unsafe { tims_close(handle) }; +} + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn get_spectrum_null_out_returns_internal() { + let handle = open_stub(); + let status = unsafe { tims_get_spectrum(handle, 0, ptr::null_mut()) }; + assert_status(status, TIMSFFI_ERR_INTERNAL); + unsafe { tims_close(handle) }; +} + +// ============================================================ +// Batch spectrum tests — universal +// ============================================================ + +#[test] +fn get_spectra_by_rt_null_handle_returns_internal() { + let mut count: u32 = 0; + let mut specs: *mut TimsFfiSpectrum = ptr::null_mut(); + let status = unsafe { + tims_get_spectra_by_rt(ptr::null_mut(), 100.0, 5, 0.0, 2.0, &mut count, &mut specs) + }; + assert_status(status, TIMSFFI_ERR_INTERNAL); +} + +// ============================================================ +// Batch spectrum tests — stub-only +// ============================================================ + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn get_spectra_by_rt_stub_returns_empty() { + let handle = open_stub(); + let mut count: u32 = 99; + let mut specs: *mut TimsFfiSpectrum = ptr::null_mut(); + let status = unsafe { + tims_get_spectra_by_rt(handle, 100.0, 5, 0.0, 2.0, &mut count, &mut specs) + }; + assert_status(status, TIMSFFI_OK); + assert_eq!(count, 0, "stub should return 0 spectra"); + assert!(specs.is_null(), "specs pointer should be null when count is 0"); + unsafe { tims_close(handle) }; +} + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn get_spectra_by_rt_n_zero_returns_empty() { + let handle = open_stub(); + let mut count: u32 = 99; + let mut specs: *mut TimsFfiSpectrum = ptr::null_mut(); + let status = unsafe { + tims_get_spectra_by_rt(handle, 100.0, 0, 0.0, 2.0, &mut count, &mut specs) + }; + assert_status(status, TIMSFFI_OK); + assert_eq!(count, 0, "n_spectra=0 should yield count=0"); + unsafe { tims_close(handle) }; +} + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn get_spectra_by_rt_negative_n_returns_empty() { + let handle = open_stub(); + let mut count: u32 = 99; + let mut specs: *mut TimsFfiSpectrum = ptr::null_mut(); + let status = unsafe { + tims_get_spectra_by_rt(handle, 100.0, -5, 0.0, 2.0, &mut count, &mut specs) + }; + assert_status(status, TIMSFFI_OK); + assert_eq!(count, 0, "negative n_spectra should yield count=0"); + unsafe { tims_close(handle) }; +} + +// ============================================================ +// Free safety — stub-only +// ============================================================ + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn free_spectrum_array_null_no_crash() { + let handle = open_stub(); + unsafe { tims_free_spectrum_array(handle, ptr::null_mut(), 0) }; + unsafe { tims_close(handle) }; +} + +#[cfg(not(feature = "with_timsrust"))] +#[test] +fn free_spectrum_array_non_null_count_zero() { + let handle = open_stub(); + // Allocate a small buffer via libc::malloc, pass count=0 so no + // per-element iteration happens — only the outer array is freed. + let buf = unsafe { + libc::malloc(std::mem::size_of::()) as *mut TimsFfiSpectrum + }; + assert!(!buf.is_null(), "malloc should succeed"); + unsafe { tims_free_spectrum_array(handle, buf, 0) }; + unsafe { tims_close(handle) }; +} diff --git a/tests_cpp/Makefile b/tests_cpp/Makefile new file mode 100644 index 0000000..e833de7 --- /dev/null +++ b/tests_cpp/Makefile @@ -0,0 +1,27 @@ +# tests_cpp/Makefile +CXX ?= g++ +CXXFLAGS := -std=c++17 -Wall -Werror -I../include +LIBDIR ?= ../target/debug +LDFLAGS := -L$(LIBDIR) -ltimsrust_cpp_bridge -Wl,-rpath,$(realpath $(LIBDIR)) + +DDA ?= +DIA ?= + +SRCS := test_abi.cpp test_smoke.cpp catch2/catch_amalgamated.cpp +OBJS := $(SRCS:.cpp=.o) +BIN := run_tests + +.PHONY: test clean + +test: $(BIN) + @echo "=== Running C++ FFI tests ===" + TIMSRUST_TEST_DATA_DDA="$(DDA)" TIMSRUST_TEST_DATA_DIA="$(DIA)" ./$(BIN) --reporter compact + +$(BIN): $(OBJS) + $(CXX) $(CXXFLAGS) -o $@ $^ $(LDFLAGS) + +%.o: %.cpp + $(CXX) $(CXXFLAGS) -c -o $@ $< + +clean: + rm -f $(OBJS) $(BIN) diff --git a/tests_cpp/fetch_catch2.sh b/tests_cpp/fetch_catch2.sh new file mode 100755 index 0000000..19fe1f0 --- /dev/null +++ b/tests_cpp/fetch_catch2.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Download Catch2 v3.5.2 amalgamated files for the C++ test suite. +# Run from the tests_cpp/ directory (or from the repo root). +set -euo pipefail + +CATCH2_VERSION="v3.5.2" +BASE_URL="https://github.com/catchorg/Catch2/releases/download/${CATCH2_VERSION}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT_DIR="${SCRIPT_DIR}/catch2" + +mkdir -p "${OUT_DIR}" + +echo "Downloading Catch2 ${CATCH2_VERSION} amalgamated files..." +curl -fsSL -o "${OUT_DIR}/catch_amalgamated.hpp" "${BASE_URL}/catch_amalgamated.hpp" +curl -fsSL -o "${OUT_DIR}/catch_amalgamated.cpp" "${BASE_URL}/catch_amalgamated.cpp" +echo "Done. Files saved to ${OUT_DIR}/" diff --git a/tests_cpp/test_abi.cpp b/tests_cpp/test_abi.cpp new file mode 100644 index 0000000..623d44a --- /dev/null +++ b/tests_cpp/test_abi.cpp @@ -0,0 +1,42 @@ +// tests_cpp/test_abi.cpp +#include "../include/timsrust_cpp_bridge.h" +#include "catch2/catch_amalgamated.hpp" +#include +#include + +// Enum value checks +TEST_CASE("Status enum values", "[abi]") { + REQUIRE(TIMSFFI_OK == 0); + REQUIRE(TIMSFFI_ERR_INVALID_UTF8 == 1); + REQUIRE(TIMSFFI_ERR_OPEN_FAILED == 2); + REQUIRE(TIMSFFI_ERR_INDEX_OOB == 3); + REQUIRE(TIMSFFI_ERR_INTERNAL == 255); +} + +// Struct size checks — verify they compile and have reasonable sizes +TEST_CASE("tims_spectrum sizeof", "[abi]") { REQUIRE(sizeof(tims_spectrum) > 0); } +TEST_CASE("tims_frame sizeof", "[abi]") { REQUIRE(sizeof(tims_frame) > 0); } +TEST_CASE("tims_swath_window sizeof", "[abi]") { REQUIRE(sizeof(tims_swath_window) > 0); } +TEST_CASE("tims_level_stats sizeof", "[abi]") { REQUIRE(sizeof(tims_level_stats) > 0); } +TEST_CASE("tims_file_info_t sizeof", "[abi]") { REQUIRE(sizeof(tims_file_info_t) > 0); } + +// Key field offset checks +TEST_CASE("tims_spectrum field offsets", "[abi]") { + REQUIRE(offsetof(tims_spectrum, rt_seconds) == 0); + REQUIRE(offsetof(tims_spectrum, mz) > offsetof(tims_spectrum, num_peaks)); + REQUIRE(offsetof(tims_spectrum, intensity) > offsetof(tims_spectrum, mz)); + REQUIRE(offsetof(tims_spectrum, im) > offsetof(tims_spectrum, intensity)); +} + +TEST_CASE("tims_frame field offsets", "[abi]") { + REQUIRE(offsetof(tims_frame, index) == 0); + REQUIRE(offsetof(tims_frame, tof_indices) > offsetof(tims_frame, num_peaks)); + REQUIRE(offsetof(tims_frame, intensities) > offsetof(tims_frame, tof_indices)); + REQUIRE(offsetof(tims_frame, scan_offsets) > offsetof(tims_frame, intensities)); +} + +TEST_CASE("tims_swath_window field offsets", "[abi]") { + REQUIRE(offsetof(tims_swath_window, mz_lower) == 0); + REQUIRE(offsetof(tims_swath_window, mz_upper) == sizeof(double)); + REQUIRE(offsetof(tims_swath_window, is_ms1) > offsetof(tims_swath_window, im_upper)); +} diff --git a/tests_cpp/test_smoke.cpp b/tests_cpp/test_smoke.cpp new file mode 100644 index 0000000..5516d9d --- /dev/null +++ b/tests_cpp/test_smoke.cpp @@ -0,0 +1,180 @@ +// tests_cpp/test_smoke.cpp +#include "../include/timsrust_cpp_bridge.h" +#include "catch2/catch_amalgamated.hpp" +#include +#include +#include +#include +#include + +static std::string get_env(const char* name) { + const char* val = std::getenv(name); + return val ? std::string(val) : std::string(); +} + +TEST_CASE("Open bad path returns error", "[smoke]") { + tims_dataset* handle = nullptr; + auto status = tims_open("/tmp/timsrust_cpp_test_nonexistent", &handle); + REQUIRE(status != TIMSFFI_OK); + char buf[256] = {}; + tims_get_last_error(nullptr, buf, sizeof(buf)); + REQUIRE(std::strlen(buf) > 0); +} + +TEST_CASE("Config builder round-trip", "[smoke]") { + auto* cfg = tims_config_create(); + REQUIRE(cfg != nullptr); + tims_config_set_smoothing_window(cfg, 3); + tims_config_set_centroiding_window(cfg, 5); + tims_config_set_calibration_tolerance(cfg, 0.01); + tims_config_set_calibrate(cfg, 1); + tims_config_free(cfg); +} + +TEST_CASE("DDA smoke test", "[smoke][dda]") { + auto dda = get_env("TIMSRUST_TEST_DATA_DDA"); + if (dda.empty()) { SKIP("TIMSRUST_TEST_DATA_DDA not set"); } + tims_dataset* handle = nullptr; + REQUIRE(tims_open(dda.c_str(), &handle) == TIMSFFI_OK); + REQUIRE(handle != nullptr); + auto n = tims_num_spectra(handle); + REQUIRE(n > 0); + tims_spectrum spec{}; + REQUIRE(tims_get_spectrum(handle, 0, &spec) == TIMSFFI_OK); + REQUIRE(spec.num_peaks > 0); + REQUIRE(spec.mz[0] > 0.0f); + tims_close(handle); +} + +TEST_CASE("DIA smoke test", "[smoke][dia]") { + auto dia = get_env("TIMSRUST_TEST_DATA_DIA"); + if (dia.empty()) { SKIP("TIMSRUST_TEST_DATA_DIA not set"); } + tims_dataset* handle = nullptr; + REQUIRE(tims_open(dia.c_str(), &handle) == TIMSFFI_OK); + unsigned int win_count = 0; + tims_swath_window* windows = nullptr; + REQUIRE(tims_get_swath_windows(handle, &win_count, &windows) == TIMSFFI_OK); + REQUIRE(win_count > 0); + tims_file_info_t info{}; + tims_file_info(handle, &info); + double mid_rt = (info.ms2.rt_min + info.ms2.rt_max) / 2.0; + unsigned int spec_count = 0; + tims_spectrum* specs = nullptr; + REQUIRE(tims_get_spectra_by_rt(handle, mid_rt, 3, 0.0, 1e15, &spec_count, &specs) == TIMSFFI_OK); + REQUIRE(spec_count > 0); + tims_free_spectrum_array(handle, specs, spec_count); + tims_free_swath_windows(handle, windows); + tims_close(handle); +} + +// ---- Config effects ---- + +TEST_CASE("Centroiding reduces peak count", "[config][dda]") { + auto dda = get_env("TIMSRUST_TEST_DATA_DDA"); + if (dda.empty()) { SKIP("TIMSRUST_TEST_DATA_DDA not set"); } + tims_dataset* h_default = nullptr; + REQUIRE(tims_open(dda.c_str(), &h_default) == TIMSFFI_OK); + tims_spectrum spec_default{}; + REQUIRE(tims_get_spectrum(h_default, 0, &spec_default) == TIMSFFI_OK); + auto peaks_default = spec_default.num_peaks; + tims_close(h_default); + + auto* cfg = tims_config_create(); + tims_config_set_centroiding_window(cfg, 10); + tims_dataset* h_centro = nullptr; + REQUIRE(tims_open_with_config(dda.c_str(), cfg, &h_centro) == TIMSFFI_OK); + tims_spectrum spec_centro{}; + REQUIRE(tims_get_spectrum(h_centro, 0, &spec_centro) == TIMSFFI_OK); + auto peaks_centro = spec_centro.num_peaks; + tims_config_free(cfg); + tims_close(h_centro); + REQUIRE(peaks_centro <= peaks_default); +} + +TEST_CASE("Smoothing changes intensities", "[config][dda]") { + auto dda = get_env("TIMSRUST_TEST_DATA_DDA"); + if (dda.empty()) { SKIP("TIMSRUST_TEST_DATA_DDA not set"); } + auto* cfg_base = tims_config_create(); + tims_config_set_smoothing_window(cfg_base, 0); + tims_dataset* h1 = nullptr; + REQUIRE(tims_open_with_config(dda.c_str(), cfg_base, &h1) == TIMSFFI_OK); + tims_config_free(cfg_base); + tims_spectrum s1{}; + REQUIRE(tims_get_spectrum(h1, 0, &s1) == TIMSFFI_OK); + std::vector int1(s1.intensity, s1.intensity + s1.num_peaks); + tims_close(h1); + + auto* cfg = tims_config_create(); + tims_config_set_smoothing_window(cfg, 5); + tims_dataset* h2 = nullptr; + REQUIRE(tims_open_with_config(dda.c_str(), cfg, &h2) == TIMSFFI_OK); + tims_config_free(cfg); + tims_spectrum s2{}; + REQUIRE(tims_get_spectrum(h2, 0, &s2) == TIMSFFI_OK); + std::vector int2(s2.intensity, s2.intensity + s2.num_peaks); + tims_close(h2); + + bool any_differ = false; + auto n = std::min(int1.size(), int2.size()); + for (size_t i = 0; i < n; ++i) { + if (std::abs(int1[i] - int2[i]) > 1e-6f) { any_differ = true; break; } + } + REQUIRE(any_differ); +} + +TEST_CASE("Calibration changes m/z values", "[config][dda]") { + auto dda = get_env("TIMSRUST_TEST_DATA_DDA"); + if (dda.empty()) { SKIP("TIMSRUST_TEST_DATA_DDA not set"); } + auto* cfg_off = tims_config_create(); + tims_config_set_calibrate(cfg_off, 0); + tims_dataset* h1 = nullptr; + REQUIRE(tims_open_with_config(dda.c_str(), cfg_off, &h1) == TIMSFFI_OK); + tims_spectrum s1{}; + REQUIRE(tims_get_spectrum(h1, 0, &s1) == TIMSFFI_OK); + std::vector mz1(s1.mz, s1.mz + s1.num_peaks); + tims_config_free(cfg_off); + tims_close(h1); + + auto* cfg_on = tims_config_create(); + tims_config_set_calibrate(cfg_on, 1); + tims_dataset* h2 = nullptr; + auto status = tims_open_with_config(dda.c_str(), cfg_on, &h2); + tims_config_free(cfg_on); + if (status != TIMSFFI_OK) { + // timsrust may panic on calibration with certain datasets + WARN("open_with_config(calibrate=on) failed (status " << status << ") — skipping comparison"); + REQUIRE(s1.num_peaks > 0); + return; + } + tims_spectrum s2{}; + REQUIRE(tims_get_spectrum(h2, 0, &s2) == TIMSFFI_OK); + std::vector mz2(s2.mz, s2.mz + s2.num_peaks); + tims_close(h2); + // At minimum both should return valid data + REQUIRE(s1.num_peaks > 0); + REQUIRE(s2.num_peaks > 0); +} + +TEST_CASE("Config combinations produce valid output", "[config][dda]") { + auto dda = get_env("TIMSRUST_TEST_DATA_DDA"); + if (dda.empty()) { SKIP("TIMSRUST_TEST_DATA_DDA not set"); } + auto* cfg = tims_config_create(); + tims_config_set_smoothing_window(cfg, 3); + tims_config_set_centroiding_window(cfg, 5); + tims_config_set_calibration_tolerance(cfg, 0.01); + tims_config_set_calibrate(cfg, 1); + tims_dataset* handle = nullptr; + auto status = tims_open_with_config(dda.c_str(), cfg, &handle); + tims_config_free(cfg); + if (status != TIMSFFI_OK) { + // timsrust may panic on certain config combinations with this dataset + WARN("open_with_config(combined config) failed (status " << status << ") — skipping"); + return; + } + tims_spectrum spec{}; + REQUIRE(tims_get_spectrum(handle, 0, &spec) == TIMSFFI_OK); + REQUIRE(spec.num_peaks > 0); + for (uint32_t i = 1; i < spec.num_peaks; ++i) { REQUIRE(spec.mz[i] >= spec.mz[i-1]); } + for (uint32_t i = 0; i < spec.num_peaks; ++i) { REQUIRE(spec.intensity[i] >= 0.0f); } + tims_close(handle); +}