Skip to main content

tensogram_ffi/
lib.rs

1// (C) Copyright 2026- ECMWF and individual contributors.
2//
3// This software is licensed under the terms of the Apache Licence Version 2.0
4// which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
5// In applying this licence, ECMWF does not waive the privileges and immunities
6// granted to it by virtue of its status as an intergovernmental organisation nor
7// does it submit to any jurisdiction.
8
9// FFI functions accept raw pointers by design — callers are responsible for
10// validity. Marking every extern "C" fn as `unsafe` would be correct but
11// makes cbindgen emit ugly signatures with no benefit to C callers.
12#![allow(clippy::not_unsafe_ptr_arg_deref)]
13
14//! Tensogram C FFI
15//!
16//! Exposes the tensogram library to C and C++ callers via opaque handles,
17//! typed accessor functions, and a flat C ABI.
18//!
19//! Memory ownership rules:
20//! - Handles returned by `tgm_*` functions are owned by the caller.
21//!   Free them with the matching `tgm_*_free` function.
22//! - Pointers returned by accessor functions (e.g. `tgm_object_shape`) are
23//!   borrowed from the handle and valid until the handle is freed.
24//! - `tgm_bytes_t` returned by encode functions must be freed with `tgm_bytes_free`.
25//!
26//! ## JSON schema for `tgm_encode`
27//!
28//! The `metadata_json` argument to `tgm_encode` is a JSON object with:
29//! - `"descriptors"` (array, required): one entry per data object. Each entry
30//!   merges tensor info and encoding pipeline info into a single object:
31//!   `type`, `ndim`, `shape`, `strides`, `dtype`, `byte_order`, `encoding`,
32//!   `filter`, `compression`. Additional keys are stored as params.
33//! - `"base"` (array, optional): per-object application metadata.
34//! - Any other top-level keys (e.g. `"mars"`) are stored under the
35//!   message-level `_extra_` map.  The CBOR metadata frame is free-form —
36//!   the wire-format version lives exclusively in the preamble (see
37//!   `plans/WIRE_FORMAT.md` §3) and must NOT be supplied by callers.  A
38//!   legacy `"version"` top-level field is tolerated for pre-0.17 schema
39//!   compatibility and silently discarded.
40
41use std::collections::BTreeMap;
42use std::ffi::{CStr, CString};
43use std::os::raw::c_char;
44use std::path::Path;
45use std::ptr;
46use std::slice;
47
48#[cfg(feature = "async")]
49pub mod async_core;
50#[cfg(feature = "async")]
51pub mod async_streaming;
52
53use tensogram::encode::MaskMethod;
54use tensogram::validate::{
55    ValidateOptions, ValidationLevel, validate_file as core_validate_file, validate_message,
56};
57use tensogram::{
58    DataObjectDescriptor, DecodeOptions, EncodeOptions, GlobalMetadata, RESERVED_KEY,
59    StreamingEncoder, TensogramError, TensogramFile, decode, decode_metadata, decode_object,
60    decode_range, encode, encode_pre_encoded, parse_hash_name, scan,
61};
62
63// ---------------------------------------------------------------------------
64// Constants
65// ---------------------------------------------------------------------------
66
67/// Wire-format version emitted and required by this build of the
68/// library.
69///
70/// Mirrors [`tensogram::WIRE_VERSION`].  The value lives in the
71/// tensogram **preamble** (see `plans/WIRE_FORMAT.md` §3) — never in
72/// the CBOR metadata frame.  Exposed here so C / C++ callers can
73/// reference it without decoding a message first.  cbindgen emits
74/// this as a `#define TGM_WIRE_VERSION 3` in the generated header.
75///
76/// The literal is mirrored from `tensogram::wire::WIRE_VERSION`
77/// because cbindgen source-parses this crate and cannot resolve
78/// cross-crate constant expressions; the [`const _: () = assert!`]
79/// below keeps the two in lockstep at compile time.
80pub const TGM_WIRE_VERSION: u16 = 3;
81const _: () = assert!(
82    TGM_WIRE_VERSION == tensogram::WIRE_VERSION,
83    "TGM_WIRE_VERSION must equal tensogram::WIRE_VERSION"
84);
85
86// ---------------------------------------------------------------------------
87// Error codes
88// ---------------------------------------------------------------------------
89
90#[repr(C)]
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum TgmError {
93    Ok = 0,
94    Framing = 1,
95    Metadata = 2,
96    Encoding = 3,
97    Compression = 4,
98    Object = 5,
99    Io = 6,
100    HashMismatch = 7,
101    InvalidArg = 8,
102    /// Returned by `tgm_*_iter_next` when iteration is exhausted.
103    EndOfIter = 9,
104    Remote = 10,
105    /// Decode-time hash verification was requested but the frame's
106    /// `HASH_PRESENT` flag is clear (see `plans/WIRE_FORMAT.md` §2.5).
107    /// Distinct from `HashMismatch` — the digest didn't disagree;
108    /// there was no digest recorded to compare against.  The
109    /// offending object index is available through
110    /// `tgm_last_error_object_index()` for the duration of the
111    /// thread-local error.
112    MissingHash = 11,
113    /// Async task exceeded its deadline; the underlying tokio future
114    /// was dropped at the next yield point.
115    Timeout = 12,
116    /// Async task observed its cancellation token fire before the
117    /// future resolved.
118    Cancelled = 13,
119}
120
121fn to_error_code(e: &TensogramError) -> TgmError {
122    match e {
123        TensogramError::Framing(_) => TgmError::Framing,
124        TensogramError::Metadata(_) => TgmError::Metadata,
125        TensogramError::Encoding(_) => TgmError::Encoding,
126        TensogramError::Compression(_) => TgmError::Compression,
127        TensogramError::Object(_) => TgmError::Object,
128        TensogramError::Io(_) => TgmError::Io,
129        TensogramError::HashMismatch { .. } => TgmError::HashMismatch,
130        TensogramError::MissingHash { .. } => TgmError::MissingHash,
131        TensogramError::Remote(_) => TgmError::Remote,
132        // `TensogramError` is `#[non_exhaustive]` so future variants
133        // can land without breaking this mapping at compile time.
134        // Until a more specific FFI code is needed, route unknown
135        // variants to the catch-all `Encoding` bucket — every
136        // existing variant maps cleanly today, and any new variant
137        // surfaces as a stable error code that callers can still
138        // handle generically through `tgm_last_error()`.
139        _ => TgmError::Encoding,
140    }
141}
142
143// Thread-local storage for the last error message.
144thread_local! {
145    static LAST_ERROR: std::cell::RefCell<Option<CString>> = const { std::cell::RefCell::new(None) };
146}
147
148fn set_last_error(msg: &str) {
149    LAST_ERROR.with(|cell| {
150        *cell.borrow_mut() = CString::new(msg).ok();
151    });
152}
153
154/// Returns a pointer to the last error message, or NULL if no error.
155/// The pointer is valid until the next FFI call on the same thread.
156#[unsafe(no_mangle)]
157pub extern "C" fn tgm_last_error() -> *const c_char {
158    LAST_ERROR.with(|cell| {
159        cell.borrow()
160            .as_ref()
161            .map(|s| s.as_ptr())
162            .unwrap_or(ptr::null())
163    })
164}
165
166// ---------------------------------------------------------------------------
167// Byte buffer
168// ---------------------------------------------------------------------------
169
170/// An owned byte buffer returned by encode functions.
171#[repr(C)]
172pub struct TgmBytes {
173    pub data: *mut u8,
174    pub len: usize,
175}
176
177/// Mask-companion options for encode entry points (see
178/// `plans/WIRE_FORMAT.md` §6.5 and `docs/src/guide/nan-inf-handling.md`).
179/// Pass a pointer to this struct to opt into NaN / ±Inf substitution
180/// with bitmask companion frames.
181///
182/// Each `*_mask_method` string is one of `"none"`, `"rle"`,
183/// `"roaring"`, `"lz4"`, `"zstd"`, or `"blosc2"`; pass `NULL` to use
184/// the library default (`"roaring"`).  Unknown names cause the
185/// owning `tgm_*_with_options` call to return
186/// [`TgmError::InvalidArg`] with a clear message via
187/// [`tgm_last_error`].
188///
189/// `small_mask_threshold_bytes` is the byte-count below which mask
190/// blobs are written as `"none"` regardless of the requested method
191/// (auto-fallback).  Pass `0` to disable the fallback.  Negative
192/// values use the library default (128).
193#[repr(C)]
194pub struct TgmEncodeMaskOptions {
195    pub allow_nan: bool,
196    pub allow_inf: bool,
197    pub nan_mask_method: *const c_char,
198    pub pos_inf_mask_method: *const c_char,
199    pub neg_inf_mask_method: *const c_char,
200    pub small_mask_threshold_bytes: isize,
201}
202
203/// Parse one of the optional C-string mask-method fields into a Rust
204/// [`MaskMethod`].  Returns the caller-supplied default on `NULL`,
205/// an `Err` naming the offending value (and accepted alternatives)
206/// for invalid UTF-8 or unknown names.
207///
208/// # Safety
209///
210/// `ptr` must either be `NULL` or point to a NUL-terminated UTF-8
211/// string with a valid Rust-bound lifetime.
212unsafe fn parse_mask_method_cstr(
213    ptr: *const c_char,
214    default: MaskMethod,
215) -> Result<MaskMethod, String> {
216    if ptr.is_null() {
217        return Ok(default);
218    }
219    let s = unsafe { CStr::from_ptr(ptr) }
220        .to_str()
221        .map_err(|_| "mask method name is not valid UTF-8".to_string())?;
222    MaskMethod::from_name(s).map_err(|e| e.to_string())
223}
224
225/// Apply the optional [`TgmEncodeMaskOptions`] pointer to an
226/// [`EncodeOptions`].  `NULL` is a no-op.  Returns an error message
227/// (routed to [`set_last_error`] by the caller) when a method name
228/// is invalid UTF-8 or unknown.
229///
230/// # Safety
231///
232/// `opts` must either be `NULL` or point to a valid
233/// `TgmEncodeMaskOptions` whose `*_mask_method` fields satisfy
234/// [`parse_mask_method_cstr`]'s safety contract.
235unsafe fn apply_mask_options(
236    encode_opts: &mut EncodeOptions,
237    opts: *const TgmEncodeMaskOptions,
238) -> Result<(), String> {
239    if opts.is_null() {
240        return Ok(());
241    }
242    let opts = unsafe { &*opts };
243    encode_opts.allow_nan = opts.allow_nan;
244    encode_opts.allow_inf = opts.allow_inf;
245    encode_opts.nan_mask_method =
246        unsafe { parse_mask_method_cstr(opts.nan_mask_method, MaskMethod::default())? };
247    encode_opts.pos_inf_mask_method =
248        unsafe { parse_mask_method_cstr(opts.pos_inf_mask_method, MaskMethod::default())? };
249    encode_opts.neg_inf_mask_method =
250        unsafe { parse_mask_method_cstr(opts.neg_inf_mask_method, MaskMethod::default())? };
251    if opts.small_mask_threshold_bytes >= 0 {
252        encode_opts.small_mask_threshold_bytes = opts.small_mask_threshold_bytes as usize;
253    }
254    Ok(())
255}
256
257/// Decode-side companion to [`TgmEncodeMaskOptions`].  Pass a pointer
258/// to opt out of canonical NaN / Inf restoration.  Pass `NULL` for
259/// the default `restore_non_finite = true`.
260#[repr(C)]
261pub struct TgmDecodeMaskOptions {
262    pub restore_non_finite: bool,
263}
264
265/// Apply the optional [`TgmDecodeMaskOptions`] pointer to a
266/// [`DecodeOptions`].  `NULL` is a no-op.
267///
268/// # Safety
269///
270/// `opts` must either be `NULL` or point to a valid
271/// `TgmDecodeMaskOptions`.
272unsafe fn apply_decode_mask_options(
273    decode_opts: &mut DecodeOptions,
274    opts: *const TgmDecodeMaskOptions,
275) {
276    if opts.is_null() {
277        return;
278    }
279    let opts = unsafe { &*opts };
280    decode_opts.restore_non_finite = opts.restore_non_finite;
281}
282
283/// Free a byte buffer returned by `tgm_encode`.
284#[unsafe(no_mangle)]
285pub extern "C" fn tgm_bytes_free(buf: TgmBytes) {
286    if !buf.data.is_null() {
287        unsafe {
288            drop(Vec::from_raw_parts(buf.data, buf.len, buf.len));
289        }
290    }
291}
292
293// ---------------------------------------------------------------------------
294// Opaque handles
295// ---------------------------------------------------------------------------
296
297/// Decoded message: global metadata + decoded (descriptor, payload) pairs.
298pub struct TgmMessage {
299    global_metadata: GlobalMetadata,
300    /// Each entry pairs a per-object descriptor with its decoded payload bytes.
301    objects: Vec<(DataObjectDescriptor, Vec<u8>)>,
302    /// Cached CStrings for dtype accessor returns (parallel to `objects`).
303    dtype_strings: Vec<CString>,
304    /// Cached CStrings for object type accessor returns.
305    type_strings: Vec<CString>,
306    /// Cached CStrings for byte order accessor returns.
307    byte_order_strings: Vec<CString>,
308    /// Cached CStrings for filter accessor returns.
309    filter_strings: Vec<CString>,
310    /// Cached CStrings for compression accessor returns.
311    compression_strings: Vec<CString>,
312    /// Cached CStrings for encoding accessor returns.
313    encoding_strings: Vec<CString>,
314    /// Cached CStrings for hash type accessor returns (None when no hash).
315    hash_type_strings: Vec<Option<CString>>,
316    /// Cached CStrings for hash value accessor returns (None when no hash).
317    hash_value_strings: Vec<Option<CString>>,
318}
319
320/// Metadata-only handle (no decoded payloads).
321pub struct TgmMetadata {
322    global_metadata: GlobalMetadata,
323    /// Cache for string accessors (key → null-terminated value).
324    cache: std::cell::RefCell<BTreeMap<String, CString>>,
325}
326
327/// File handle.
328pub struct TgmFile {
329    file: TensogramFile,
330    /// Cached path string for `tgm_file_path`.
331    path_string: CString,
332}
333
334/// Scan result: array of (offset, length) pairs.
335#[repr(C)]
336#[derive(Clone, Copy)]
337pub struct TgmScanEntry {
338    pub offset: usize,
339    pub length: usize,
340}
341
342/// Opaque handle for scan results.
343pub struct TgmScanResult {
344    entries: Vec<TgmScanEntry>,
345}
346
347// ---------------------------------------------------------------------------
348// JSON deserialization helpers for the encode API
349// ---------------------------------------------------------------------------
350
351/// Intermediate struct used to parse the flat JSON provided to `tgm_encode`.
352///
353/// The caller passes a single JSON object that contains both global metadata
354/// fields (any application-specific namespaced keys such as `"mars"`) and a
355/// `"descriptors"` array of per-object descriptor objects.
356///
357/// A legacy `"version"` field is tolerated (parsed and then discarded); the
358/// wire-format version lives in the preamble (see
359/// `plans/WIRE_FORMAT.md` §3) and is not settable by callers.
360#[derive(serde::Deserialize)]
361struct EncodeJson {
362    /// Legacy field: pre-0.17 callers wrote `{"version": 3, …}`.  v3
363    /// ignores the value — it's parsed solely so the JSON schema stays
364    /// backwards-compatible for third-party tools.  `None` means the
365    /// modern, free-form schema was used.
366    #[serde(default)]
367    version: Option<u16>,
368    #[serde(default)]
369    descriptors: Vec<DataObjectDescriptor>,
370    /// Per-object metadata array (one entry per data object).
371    #[serde(default)]
372    base: Vec<BTreeMap<String, serde_json::Value>>,
373    /// All remaining top-level keys become `GlobalMetadata::extra`.
374    #[serde(flatten)]
375    extra: BTreeMap<String, serde_json::Value>,
376}
377
378/// Convert a `serde_json::Value` to a `ciborium::Value` for storage in
379/// `GlobalMetadata::extra`.
380fn json_to_cbor(v: serde_json::Value) -> ciborium::Value {
381    match v {
382        serde_json::Value::Null => ciborium::Value::Null,
383        serde_json::Value::Bool(b) => ciborium::Value::Bool(b),
384        serde_json::Value::Number(n) => {
385            if let Some(i) = n.as_i64() {
386                ciborium::Value::Integer(i.into())
387            } else if let Some(f) = n.as_f64() {
388                ciborium::Value::Float(f)
389            } else {
390                ciborium::Value::Null
391            }
392        }
393        serde_json::Value::String(s) => ciborium::Value::Text(s),
394        serde_json::Value::Array(arr) => {
395            ciborium::Value::Array(arr.into_iter().map(json_to_cbor).collect())
396        }
397        serde_json::Value::Object(map) => ciborium::Value::Map(
398            map.into_iter()
399                .map(|(k, v)| (ciborium::Value::Text(k), json_to_cbor(v)))
400                .collect(),
401        ),
402    }
403}
404
405/// Parse the flat JSON blob into a `GlobalMetadata` and a list of
406/// `DataObjectDescriptor`s.
407///
408/// The `"descriptors"` and `"base"` keys are consumed; all remaining
409/// keys — including a legacy top-level `"version"` — are forwarded
410/// into `GlobalMetadata::extra` as CBOR values.  The wire-format
411/// version itself lives in the preamble (see `plans/WIRE_FORMAT.md`
412/// §§3, 6.1); a caller-supplied `version` is treated as a free-form
413/// annotation, matching the Python / TypeScript / Rust-core contract.
414/// On key collision with an explicit `"_extra_"` / `"extra"` entry,
415/// the explicit entry wins — "explicit beats implicit".
416fn parse_encode_json(
417    json_str: &str,
418) -> Result<(GlobalMetadata, Vec<DataObjectDescriptor>), String> {
419    let parsed: EncodeJson = serde_json::from_str(json_str)
420        .map_err(|e| format!("failed to parse metadata JSON: {e}"))?;
421
422    let cbor_base: Vec<BTreeMap<String, ciborium::Value>> = parsed
423        .base
424        .into_iter()
425        .map(|entry| {
426            entry
427                .into_iter()
428                .map(|(k, v)| (k, json_to_cbor(v)))
429                .collect()
430        })
431        .collect();
432
433    // Validate: no _reserved_ keys in base entries (library-managed namespace)
434    for (i, entry) in cbor_base.iter().enumerate() {
435        if entry.contains_key(RESERVED_KEY) {
436            return Err(format!(
437                "base[{i}] must not contain '{RESERVED_KEY}' key — the encoder populates it"
438            ));
439        }
440    }
441
442    let cbor_extra = merge_flattened_extras_with_version(parsed.extra, parsed.version)?;
443
444    let global_metadata = GlobalMetadata {
445        base: cbor_base,
446        extra: cbor_extra,
447        ..Default::default()
448    };
449
450    Ok((global_metadata, parsed.descriptors))
451}
452
453/// Merge a flattened-catch-all JSON map and an optional legacy
454/// `version` integer into a single `_extra_` CBOR map under the
455/// free-form contract.
456///
457/// The `#[serde(flatten)]` catch-all sees any explicit
458/// `"_extra_"` / `"extra"` the caller supplied as just another
459/// top-level key.  To match the Python / TypeScript / Rust-core
460/// contract, we pull the explicit section out *first* and use it as
461/// the authoritative source; every other flattened key becomes a
462/// free-form entry that only fills slots the explicit section did
463/// not already claim.  `version` is handled the same way —
464/// `explicit beats implicit`.
465///
466/// The `"extra"` convenience alias (no underscores) is accepted for
467/// parity with the Python binding, which has supported both
468/// spellings since the 0.6 metadata refactor.  Using both is an
469/// error (ambiguous).
470fn merge_flattened_extras_with_version(
471    mut flattened: BTreeMap<String, serde_json::Value>,
472    legacy_version: Option<u16>,
473) -> Result<BTreeMap<String, ciborium::Value>, String> {
474    fn take_extra_map(
475        flattened: &mut BTreeMap<String, serde_json::Value>,
476        key: &str,
477    ) -> Result<Option<BTreeMap<String, serde_json::Value>>, String> {
478        match flattened.remove(key) {
479            None => Ok(None),
480            Some(serde_json::Value::Object(map)) => Ok(Some(map.into_iter().collect())),
481            Some(_) => Err(format!(
482                "'{key}' must be a JSON object when supplied at the top level"
483            )),
484        }
485    }
486
487    let under = take_extra_map(&mut flattened, "_extra_")?;
488    let plain = take_extra_map(&mut flattened, "extra")?;
489    let explicit_extra = match (under, plain) {
490        (Some(_), Some(_)) => {
491            return Err(
492                "both '_extra_' and 'extra' supplied at the top level — choose one".to_string(),
493            );
494        }
495        (Some(m), None) | (None, Some(m)) => m,
496        (None, None) => BTreeMap::new(),
497    };
498
499    let mut cbor_extra: BTreeMap<String, ciborium::Value> = explicit_extra
500        .into_iter()
501        .map(|(k, v)| (k, json_to_cbor(v)))
502        .collect();
503    for (k, v) in flattened {
504        // Explicit `_extra_` beats implicit free-form top-level keys
505        // on collision (explicit beats implicit).
506        cbor_extra.entry(k).or_insert_with(|| json_to_cbor(v));
507    }
508    if let Some(v) = legacy_version {
509        cbor_extra
510            .entry("version".to_string())
511            .or_insert_with(|| ciborium::Value::Integer(u64::from(v).into()));
512    }
513    Ok(cbor_extra)
514}
515
516// ---------------------------------------------------------------------------
517// Message cache builder
518// ---------------------------------------------------------------------------
519
520/// Pre-built CString caches for all descriptor string fields.
521struct MessageCaches {
522    dtype_strings: Vec<CString>,
523    type_strings: Vec<CString>,
524    byte_order_strings: Vec<CString>,
525    filter_strings: Vec<CString>,
526    compression_strings: Vec<CString>,
527    encoding_strings: Vec<CString>,
528    hash_type_strings: Vec<Option<CString>>,
529    hash_value_strings: Vec<Option<CString>>,
530}
531
532/// Extract each data-object frame's inline hash slot from a
533/// single-message wire buffer, via the cheap
534/// [`tensogram::framing::data_object_inline_hashes`] walker.
535///
536/// Returns one entry per `NTensorFrame` in emission order:
537/// `Some(digest)` when the slot is populated, `None` when it
538/// is zero (message-level `HASHES_PRESENT = 0`, or the frame
539/// wasn't hashed).  Returns an empty `Vec` if the buffer
540/// doesn't contain a parseable message — the preceding
541/// `decode()` in every FFI caller will already have surfaced
542/// any structural error.
543///
544/// Cheaper than going through `decode_message` because it
545/// parses only frame headers, not CBOR descriptors.  Matters
546/// for messages with thousands of objects where we'd otherwise
547/// pay a CBOR-parse hit just to locate each inline slot.
548fn extract_inline_hashes(buf: &[u8]) -> Vec<Option<u64>> {
549    use tensogram::framing::{data_object_inline_hashes, scan};
550
551    let messages = scan(buf);
552    let Some(&(msg_off, msg_len)) = messages.first() else {
553        return Vec::new();
554    };
555    let msg = &buf[msg_off..msg_off + msg_len];
556    data_object_inline_hashes(msg).unwrap_or_default()
557}
558
559/// Build all CString caches from the object descriptors and
560/// inline hash slots.
561///
562/// `inline_hashes` is expected to be either empty (no hash data
563/// available — every per-object entry becomes `None`) or the
564/// same length as `objects`.  A longer or shorter slice is
565/// silently truncated / padded with `None` to match `objects`.
566fn build_message_caches(
567    objects: &[(DataObjectDescriptor, Vec<u8>)],
568    inline_hashes: &[Option<u64>],
569) -> MessageCaches {
570    let dtype_strings = objects
571        .iter()
572        .map(|(desc, _)| CString::new(desc.dtype.to_string()).unwrap_or_default())
573        .collect();
574    let type_strings = objects
575        .iter()
576        .map(|(desc, _)| CString::new(desc.obj_type.as_str()).unwrap_or_default())
577        .collect();
578    let byte_order_strings = objects
579        .iter()
580        .map(|(desc, _)| {
581            let s = match desc.byte_order {
582                tensogram::ByteOrder::Big => "big",
583                tensogram::ByteOrder::Little => "little",
584            };
585            CString::new(s).unwrap_or_default()
586        })
587        .collect();
588    let filter_strings = objects
589        .iter()
590        .map(|(desc, _)| CString::new(desc.filter.as_str()).unwrap_or_default())
591        .collect();
592    let compression_strings = objects
593        .iter()
594        .map(|(desc, _)| CString::new(desc.compression.as_str()).unwrap_or_default())
595        .collect();
596    let encoding_strings = objects
597        .iter()
598        .map(|(desc, _)| CString::new(desc.encoding.as_str()).unwrap_or_default())
599        .collect();
600
601    // v3: per-object hash lives in the frame footer's inline slot
602    // (see `plans/WIRE_FORMAT.md` §2.4).  When the caller has
603    // computed the inline slots, surface them through the two
604    // existing accessors; otherwise keep the entries `None`.
605    let hash_type_strings: Vec<Option<CString>> = (0..objects.len())
606        .map(|i| {
607            inline_hashes
608                .get(i)
609                .and_then(|h| h.as_ref())
610                .map(|_| CString::new("xxh3").unwrap_or_default())
611        })
612        .collect();
613    let hash_value_strings: Vec<Option<CString>> = (0..objects.len())
614        .map(|i| {
615            inline_hashes
616                .get(i)
617                .and_then(|h| h.as_ref())
618                .map(|digest| CString::new(format!("{digest:016x}")).unwrap_or_default())
619        })
620        .collect();
621
622    MessageCaches {
623        dtype_strings,
624        type_strings,
625        byte_order_strings,
626        filter_strings,
627        compression_strings,
628        encoding_strings,
629        hash_type_strings,
630        hash_value_strings,
631    }
632}
633
634// ---------------------------------------------------------------------------
635// Shared encode argument parsing
636// ---------------------------------------------------------------------------
637
638/// Parsed and validated arguments shared by `tgm_encode` and `tgm_file_append`.
639struct ParsedEncode<'a> {
640    global_metadata: GlobalMetadata,
641    descriptors: Vec<DataObjectDescriptor>,
642    data_slices: Vec<&'a [u8]>,
643    options: EncodeOptions,
644}
645
646/// Parse the hash algorithm from a nullable C string pointer.
647///
648/// Returns `Ok(false)` when the pointer is null (the C-FFI
649/// convention: `hash_algo = NULL` means "no hashing"), `Ok(true)`
650/// when the caller named the canonical algorithm `"xxh3"`,
651/// `Ok(false)` for the explicit `"none"`, and `Err((code, message))`
652/// on parse failure.
653///
654/// **Important — diverges from the Rust API default.** The Rust
655/// [`tensogram::parse_hash_name`] helper treats `None` as "use the
656/// default = hashing on", which matches `EncodeOptions::default()`.
657/// The FFI keeps the v2 convention `NULL → off` because the C-call
658/// idiom is "if you want a feature, name it; if you don't, pass
659/// NULL" and the FFI's `tgm_encode_*` functions always require an
660/// explicit `hash_algo` argument anyway.
661pub(crate) fn parse_hash_algo(hash_algo: *const c_char) -> Result<bool, (TgmError, String)> {
662    if hash_algo.is_null() {
663        return Ok(false);
664    }
665    let s = unsafe { CStr::from_ptr(hash_algo) }.to_str().map_err(|_| {
666        (
667            TgmError::InvalidArg,
668            "invalid UTF-8 in hash_algo".to_string(),
669        )
670    })?;
671    parse_hash_name(Some(s)).map_err(|e| (TgmError::InvalidArg, e.to_string()))
672}
673
674/// Collect data slices from parallel C arrays with null-pointer validation.
675///
676/// # Safety
677///
678/// `data_ptrs` and `data_lens` must point to valid arrays of at least
679/// `num_objects` elements. Each `data_ptrs[i]` must be valid for
680/// `data_lens[i]` bytes (or may be null only when `data_lens[i] == 0`).
681unsafe fn collect_data_slices<'a>(
682    data_ptrs: *const *const u8,
683    data_lens: *const usize,
684    num_objects: usize,
685) -> Result<Vec<&'a [u8]>, (TgmError, String)> {
686    if num_objects == 0 {
687        return Ok(vec![]);
688    }
689    if data_ptrs.is_null() || data_lens.is_null() {
690        return Err((
691            TgmError::InvalidArg,
692            "null data_ptrs or data_lens".to_string(),
693        ));
694    }
695    let ptrs = unsafe { slice::from_raw_parts(data_ptrs, num_objects) };
696    let lens = unsafe { slice::from_raw_parts(data_lens, num_objects) };
697    for (i, (&p, &l)) in ptrs.iter().zip(lens.iter()).enumerate() {
698        if p.is_null() && l > 0 {
699            return Err((
700                TgmError::InvalidArg,
701                format!("null data pointer at index {i}"),
702            ));
703        }
704    }
705    Ok(ptrs
706        .iter()
707        .zip(lens.iter())
708        .map(|(&p, &l)| {
709            if l == 0 {
710                // Avoid calling slice::from_raw_parts with a potentially null
711                // pointer when length is zero — that is UB even for zero-length
712                // slices per the Rust reference.
713                &[] as &[u8]
714            } else {
715                unsafe { slice::from_raw_parts(p, l) }
716            }
717        })
718        .collect())
719}
720
721/// Parse and validate the common arguments for `tgm_encode` / `tgm_file_append`.
722///
723/// # Safety
724///
725/// All pointer arguments must satisfy the same contracts as the public FFI
726/// functions that delegate to this helper.
727unsafe fn parse_encode_args<'a>(
728    json_str: &str,
729    data_ptrs: *const *const u8,
730    data_lens: *const usize,
731    num_objects: usize,
732    hash_algo: *const c_char,
733    threads: u32,
734) -> Result<ParsedEncode<'a>, (TgmError, String)> {
735    let (global_metadata, descriptors) =
736        parse_encode_json(json_str).map_err(|e| (TgmError::Metadata, e))?;
737
738    if descriptors.len() != num_objects {
739        return Err((
740            TgmError::InvalidArg,
741            format!(
742                "descriptors array length {} does not match num_objects {}",
743                descriptors.len(),
744                num_objects
745            ),
746        ));
747    }
748
749    let data_slices = unsafe { collect_data_slices(data_ptrs, data_lens, num_objects) }?;
750    let hashing = parse_hash_algo(hash_algo)?;
751    let options = EncodeOptions {
752        hashing,
753        threads,
754        ..Default::default()
755    };
756
757    Ok(ParsedEncode {
758        global_metadata,
759        descriptors,
760        data_slices,
761        options,
762    })
763}
764
765// ---------------------------------------------------------------------------
766// Encode
767// ---------------------------------------------------------------------------
768
769/// Encode a Tensogram message from JSON metadata and raw data slices.
770///
771/// `metadata_json`: null-terminated UTF-8 JSON string with:
772///   - `"descriptors"` (array of per-object descriptor objects, required)
773///   - `"base"` (array of per-object application metadata, optional)
774///   - any other top-level keys (e.g. `"mars"`) flow into `_extra_`
775///
776/// A legacy `"version"` top-level field is tolerated and silently
777/// discarded — the wire-format version lives in the preamble, not in
778/// the CBOR metadata frame (see `plans/WIRE_FORMAT.md` §§3, 6.1).
779///
780/// `data_ptrs` / `data_lens`: arrays of length `num_objects`, raw bytes per object.
781///
782/// `hash_algo`: null-terminated string ("xxh3") or NULL for no hash.
783///
784/// On success returns `TgmError::Ok` and fills `out` with the encoded bytes.
785/// The caller must free `out` with `tgm_bytes_free`.
786///
787/// 0.17+: encode rejects non-finite values (NaN / ±Inf) by default.
788/// Use [`tgm_encode_with_options`] with a
789/// [`TgmEncodeMaskOptions`] pointer (`allow_nan` / `allow_inf`) to
790/// opt into NaN / Inf substitution with bitmask companion frames;
791/// this entry point always uses the default reject policy.
792#[unsafe(no_mangle)]
793pub extern "C" fn tgm_encode(
794    metadata_json: *const c_char,
795    data_ptrs: *const *const u8,
796    data_lens: *const usize,
797    num_objects: usize,
798    hash_algo: *const c_char,
799    threads: u32,
800    out: *mut TgmBytes,
801) -> TgmError {
802    if metadata_json.is_null() || out.is_null() {
803        set_last_error("null argument");
804        return TgmError::InvalidArg;
805    }
806
807    let json_str = match unsafe { CStr::from_ptr(metadata_json) }.to_str() {
808        Ok(s) => s,
809        Err(e) => {
810            set_last_error(&format!("invalid UTF-8 in metadata_json: {e}"));
811            return TgmError::InvalidArg;
812        }
813    };
814
815    let parsed = match unsafe {
816        parse_encode_args(
817            json_str,
818            data_ptrs,
819            data_lens,
820            num_objects,
821            hash_algo,
822            threads,
823        )
824    } {
825        Ok(p) => p,
826        Err((code, msg)) => {
827            set_last_error(&msg);
828            return code;
829        }
830    };
831
832    // Build (descriptor, data) pairs for the encode API
833    let pairs: Vec<(&DataObjectDescriptor, &[u8])> = parsed
834        .descriptors
835        .iter()
836        .zip(parsed.data_slices.iter())
837        .map(|(d, s)| (d, *s))
838        .collect();
839
840    match encode(&parsed.global_metadata, &pairs, &parsed.options) {
841        Ok(bytes) => {
842            // Rebuild via boxed slice to guarantee capacity == len for tgm_bytes_free.
843            let mut bytes = bytes.into_boxed_slice().into_vec();
844            let result = TgmBytes {
845                data: bytes.as_mut_ptr(),
846                len: bytes.len(),
847            };
848            std::mem::forget(bytes); // ownership transferred to C
849            unsafe {
850                *out = result;
851            }
852            TgmError::Ok
853        }
854        Err(e) => {
855            set_last_error(&e.to_string());
856            to_error_code(&e)
857        }
858    }
859}
860
861/// Encode with explicit NaN / Inf mask-companion options.
862///
863/// Like [`tgm_encode`] but takes a [`TgmEncodeMaskOptions`] pointer
864/// (nullable — `NULL` behaves like [`tgm_encode`]'s default reject
865/// policy).  All other arguments are identical.
866#[unsafe(no_mangle)]
867#[allow(clippy::too_many_arguments)]
868pub extern "C" fn tgm_encode_with_options(
869    metadata_json: *const c_char,
870    data_ptrs: *const *const u8,
871    data_lens: *const usize,
872    num_objects: usize,
873    hash_algo: *const c_char,
874    threads: u32,
875    mask_options: *const TgmEncodeMaskOptions,
876    out: *mut TgmBytes,
877) -> TgmError {
878    if metadata_json.is_null() || out.is_null() {
879        set_last_error("null argument");
880        return TgmError::InvalidArg;
881    }
882
883    let json_str = match unsafe { CStr::from_ptr(metadata_json) }.to_str() {
884        Ok(s) => s,
885        Err(e) => {
886            set_last_error(&format!("invalid UTF-8 in metadata_json: {e}"));
887            return TgmError::InvalidArg;
888        }
889    };
890
891    let mut parsed = match unsafe {
892        parse_encode_args(
893            json_str,
894            data_ptrs,
895            data_lens,
896            num_objects,
897            hash_algo,
898            threads,
899        )
900    } {
901        Ok(p) => p,
902        Err((code, msg)) => {
903            set_last_error(&msg);
904            return code;
905        }
906    };
907    if let Err(msg) = unsafe { apply_mask_options(&mut parsed.options, mask_options) } {
908        set_last_error(&msg);
909        return TgmError::InvalidArg;
910    }
911
912    let pairs: Vec<(&DataObjectDescriptor, &[u8])> = parsed
913        .descriptors
914        .iter()
915        .zip(parsed.data_slices.iter())
916        .map(|(d, s)| (d, *s))
917        .collect();
918
919    match encode(&parsed.global_metadata, &pairs, &parsed.options) {
920        Ok(bytes) => {
921            let mut bytes = bytes.into_boxed_slice().into_vec();
922            let result = TgmBytes {
923                data: bytes.as_mut_ptr(),
924                len: bytes.len(),
925            };
926            std::mem::forget(bytes);
927            unsafe {
928                *out = result;
929            }
930            TgmError::Ok
931        }
932        Err(e) => {
933            set_last_error(&e.to_string());
934            to_error_code(&e)
935        }
936    }
937}
938
939/// Decode with explicit NaN / Inf restoration options.
940///
941/// Like [`tgm_decode`] but takes a [`TgmDecodeMaskOptions`] pointer
942/// (nullable — `NULL` behaves like [`tgm_decode`]'s default
943/// `restore_non_finite = true`).
944#[unsafe(no_mangle)]
945#[allow(clippy::too_many_arguments)]
946pub extern "C" fn tgm_decode_with_options(
947    buf: *const u8,
948    buf_len: usize,
949    native_byte_order: i32,
950    threads: u32,
951    verify_hash: i32,
952    mask_options: *const TgmDecodeMaskOptions,
953    out: *mut *mut TgmMessage,
954) -> TgmError {
955    if buf.is_null() || out.is_null() {
956        set_last_error("null argument");
957        return TgmError::InvalidArg;
958    }
959
960    let data = unsafe { slice::from_raw_parts(buf, buf_len) };
961    let mut options = DecodeOptions {
962        native_byte_order: native_byte_order != 0,
963        threads,
964        verify_hash: verify_hash != 0,
965        ..Default::default()
966    };
967    unsafe { apply_decode_mask_options(&mut options, mask_options) };
968
969    match decode(data, &options) {
970        Ok((global_metadata, objects)) => {
971            let inline_hashes = extract_inline_hashes(data);
972            let caches = build_message_caches(&objects, &inline_hashes);
973            let msg = Box::new(TgmMessage {
974                global_metadata,
975                objects,
976                dtype_strings: caches.dtype_strings,
977                type_strings: caches.type_strings,
978                byte_order_strings: caches.byte_order_strings,
979                filter_strings: caches.filter_strings,
980                compression_strings: caches.compression_strings,
981                encoding_strings: caches.encoding_strings,
982                hash_type_strings: caches.hash_type_strings,
983                hash_value_strings: caches.hash_value_strings,
984            });
985            unsafe {
986                *out = Box::into_raw(msg);
987            }
988            TgmError::Ok
989        }
990        Err(e) => {
991            set_last_error(&e.to_string());
992            to_error_code(&e)
993        }
994    }
995}
996
997/// Streaming-encoder constructor with NaN / Inf mask-companion options.
998///
999/// Like [`tgm_streaming_encoder_create`] but takes a
1000/// [`TgmEncodeMaskOptions`] pointer.  `NULL` behaves like the default
1001/// reject policy.
1002#[unsafe(no_mangle)]
1003#[allow(clippy::too_many_arguments)]
1004pub extern "C" fn tgm_streaming_encoder_create_with_options(
1005    path: *const c_char,
1006    metadata_json: *const c_char,
1007    hash_algo: *const c_char,
1008    threads: u32,
1009    mask_options: *const TgmEncodeMaskOptions,
1010    out: *mut *mut TgmStreamingEncoder,
1011) -> TgmError {
1012    // Delegate to the existing tgm_streaming_encoder_create for the
1013    // validation + file-creation side-effects, then — if that
1014    // succeeded AND the caller passed a non-NULL mask_options — no
1015    // further adjustment is needed because the encoder has been
1016    // constructed with default options.  For an opt-in mask-aware
1017    // encoder we take a direct path through StreamingEncoder::new
1018    // with the full options set.
1019    //
1020    // Rationale for the direct path: the library-level EncodeOptions
1021    // is snapshotted at construction in StreamingEncoder, so we can't
1022    // retrofit mask options after the fact.  We therefore replicate
1023    // the existing validation + open logic here, pointer-for-pointer.
1024    if path.is_null() || metadata_json.is_null() || out.is_null() {
1025        set_last_error("null argument");
1026        return TgmError::InvalidArg;
1027    }
1028    let path_str = match unsafe { CStr::from_ptr(path) }.to_str() {
1029        Ok(s) => s,
1030        Err(e) => {
1031            set_last_error(&format!("invalid UTF-8 in path: {e}"));
1032            return TgmError::InvalidArg;
1033        }
1034    };
1035    let json_str = match unsafe { CStr::from_ptr(metadata_json) }.to_str() {
1036        Ok(s) => s,
1037        Err(e) => {
1038            set_last_error(&format!("invalid UTF-8 in metadata_json: {e}"));
1039            return TgmError::InvalidArg;
1040        }
1041    };
1042    let global_metadata = match parse_streaming_metadata_json(json_str) {
1043        Ok(m) => m,
1044        Err(e) => {
1045            set_last_error(&e);
1046            return TgmError::Metadata;
1047        }
1048    };
1049    let hashing = match parse_hash_algo(hash_algo) {
1050        Ok(b) => b,
1051        Err((code, msg)) => {
1052            set_last_error(&msg);
1053            return code;
1054        }
1055    };
1056    let file = match std::fs::File::create(path_str) {
1057        Ok(f) => f,
1058        Err(e) => {
1059            set_last_error(&e.to_string());
1060            return TgmError::Io;
1061        }
1062    };
1063    let mut options = EncodeOptions {
1064        hashing,
1065        threads,
1066        ..Default::default()
1067    };
1068    if let Err(msg) = unsafe { apply_mask_options(&mut options, mask_options) } {
1069        set_last_error(&msg);
1070        return TgmError::InvalidArg;
1071    }
1072    let writer = std::io::BufWriter::new(file);
1073    match StreamingEncoder::new(writer, &global_metadata, &options) {
1074        Ok(enc) => {
1075            let handle = Box::new(TgmStreamingEncoder { inner: Some(enc) });
1076            unsafe {
1077                *out = Box::into_raw(handle);
1078            }
1079            TgmError::Ok
1080        }
1081        Err(e) => {
1082            set_last_error(&e.to_string());
1083            to_error_code(&e)
1084        }
1085    }
1086}
1087
1088/// Append a message to a file with explicit NaN / Inf mask-companion options.
1089///
1090/// Like [`tgm_file_append`] but takes a [`TgmEncodeMaskOptions`]
1091/// pointer.  `NULL` behaves like the default reject policy.
1092#[unsafe(no_mangle)]
1093#[allow(clippy::too_many_arguments)]
1094pub extern "C" fn tgm_file_append_with_options(
1095    file: *mut TgmFile,
1096    metadata_json: *const c_char,
1097    data_ptrs: *const *const u8,
1098    data_lens: *const usize,
1099    num_objects: usize,
1100    hash_algo: *const c_char,
1101    threads: u32,
1102    mask_options: *const TgmEncodeMaskOptions,
1103) -> TgmError {
1104    if file.is_null() || metadata_json.is_null() {
1105        set_last_error("null argument");
1106        return TgmError::InvalidArg;
1107    }
1108    let json_str = match unsafe { CStr::from_ptr(metadata_json) }.to_str() {
1109        Ok(s) => s,
1110        Err(e) => {
1111            set_last_error(&format!("invalid UTF-8 in metadata_json: {e}"));
1112            return TgmError::InvalidArg;
1113        }
1114    };
1115    let mut parsed = match unsafe {
1116        parse_encode_args(
1117            json_str,
1118            data_ptrs,
1119            data_lens,
1120            num_objects,
1121            hash_algo,
1122            threads,
1123        )
1124    } {
1125        Ok(p) => p,
1126        Err((code, msg)) => {
1127            set_last_error(&msg);
1128            return code;
1129        }
1130    };
1131    if let Err(msg) = unsafe { apply_mask_options(&mut parsed.options, mask_options) } {
1132        set_last_error(&msg);
1133        return TgmError::InvalidArg;
1134    }
1135    let pairs: Vec<(&DataObjectDescriptor, &[u8])> = parsed
1136        .descriptors
1137        .iter()
1138        .zip(parsed.data_slices.iter())
1139        .map(|(d, s)| (d, *s))
1140        .collect();
1141    let f = unsafe { &mut (*file).file };
1142    match f.append(&parsed.global_metadata, &pairs, &parsed.options) {
1143        Ok(()) => TgmError::Ok,
1144        Err(e) => {
1145            set_last_error(&e.to_string());
1146            to_error_code(&e)
1147        }
1148    }
1149}
1150
1151/// Encode a Tensogram message from JSON metadata and pre-encoded payload bytes.
1152///
1153/// Like `tgm_encode`, but each `data_ptrs[i]` slice must already be encoded
1154/// according to the matching descriptor's `encoding` / `filter` / `compression`
1155/// pipeline. The library does not run the encoding pipeline again — it writes
1156/// the caller-provided bytes directly into the wire-format payload after
1157/// validating that the descriptor's pipeline configuration is well-formed.
1158///
1159/// `metadata_json`: same flat JSON schema as `tgm_encode` (`version`,
1160///   `descriptors`, optional `base`, plus arbitrary extra top-level keys).
1161///
1162/// `data_ptrs` / `data_lens`: arrays of length `num_objects` pointing at
1163///   already-encoded payload bytes (one entry per descriptor).
1164///
1165/// `hash_algo`: null-terminated string ("xxh3") or NULL for no hash. The
1166///   library always recomputes the hash over the caller's bytes; any
1167///   `hash` field embedded in the descriptor JSON is ignored and overwritten.
1168///
1169/// Notes for compression-aware decoding:
1170/// - For `szip` compression, callers SHOULD include `szip_block_offsets`
1171///   (a list of bit offsets into the compressed payload) inside the
1172///   matching descriptor's params so that `tgm_decode_range` can locate
1173///   szip block boundaries without rescanning the compressed stream.
1174/// - Other pipeline params (e.g. `simple_packing` reference value, scale
1175///   factors) must also be present in the descriptor — they are not
1176///   inferred from the bytes.
1177///
1178/// On success returns `TgmError::Ok` and fills `out` with the encoded message.
1179/// The caller must free `out` with `tgm_bytes_free`.
1180#[unsafe(no_mangle)]
1181pub extern "C" fn tgm_encode_pre_encoded(
1182    metadata_json: *const c_char,
1183    data_ptrs: *const *const u8,
1184    data_lens: *const usize,
1185    num_objects: usize,
1186    hash_algo: *const c_char,
1187    threads: u32,
1188    out: *mut TgmBytes,
1189) -> TgmError {
1190    if metadata_json.is_null() || out.is_null() {
1191        set_last_error("null argument");
1192        return TgmError::InvalidArg;
1193    }
1194
1195    let json_str = match unsafe { CStr::from_ptr(metadata_json) }.to_str() {
1196        Ok(s) => s,
1197        Err(e) => {
1198            set_last_error(&format!("invalid UTF-8 in metadata_json: {e}"));
1199            return TgmError::InvalidArg;
1200        }
1201    };
1202
1203    let parsed = match unsafe {
1204        parse_encode_args(
1205            json_str,
1206            data_ptrs,
1207            data_lens,
1208            num_objects,
1209            hash_algo,
1210            threads,
1211        )
1212    } {
1213        Ok(p) => p,
1214        Err((code, msg)) => {
1215            set_last_error(&msg);
1216            return code;
1217        }
1218    };
1219
1220    // Build (descriptor, pre-encoded data) pairs for the pre-encoded API.
1221    let pairs: Vec<(&DataObjectDescriptor, &[u8])> = parsed
1222        .descriptors
1223        .iter()
1224        .zip(parsed.data_slices.iter())
1225        .map(|(d, s)| (d, *s))
1226        .collect();
1227
1228    match encode_pre_encoded(&parsed.global_metadata, &pairs, &parsed.options) {
1229        Ok(bytes) => {
1230            // Rebuild via boxed slice to guarantee capacity == len for tgm_bytes_free.
1231            let mut bytes = bytes.into_boxed_slice().into_vec();
1232            let result = TgmBytes {
1233                data: bytes.as_mut_ptr(),
1234                len: bytes.len(),
1235            };
1236            std::mem::forget(bytes); // ownership transferred to C
1237            unsafe {
1238                *out = result;
1239            }
1240            TgmError::Ok
1241        }
1242        Err(e) => {
1243            set_last_error(&e.to_string());
1244            to_error_code(&e)
1245        }
1246    }
1247}
1248
1249// ---------------------------------------------------------------------------
1250// Decode
1251// ---------------------------------------------------------------------------
1252
1253/// Decode a complete message (global metadata + all object payloads).
1254///
1255/// `buf` / `buf_len`: the wire-format message bytes.
1256///
1257/// On success, fills `out` with a `TgmMessage` handle.
1258/// Free with `tgm_message_free`.
1259#[unsafe(no_mangle)]
1260pub extern "C" fn tgm_decode(
1261    buf: *const u8,
1262    buf_len: usize,
1263    native_byte_order: i32,
1264    threads: u32,
1265    verify_hash: i32,
1266    out: *mut *mut TgmMessage,
1267) -> TgmError {
1268    if buf.is_null() || out.is_null() {
1269        set_last_error("null argument");
1270        return TgmError::InvalidArg;
1271    }
1272
1273    let data = unsafe { slice::from_raw_parts(buf, buf_len) };
1274    let options = DecodeOptions {
1275        native_byte_order: native_byte_order != 0,
1276        threads,
1277        verify_hash: verify_hash != 0,
1278        ..Default::default()
1279    };
1280
1281    match decode(data, &options) {
1282        Ok((global_metadata, objects)) => {
1283            let inline_hashes = extract_inline_hashes(data);
1284            let caches = build_message_caches(&objects, &inline_hashes);
1285            let msg = Box::new(TgmMessage {
1286                global_metadata,
1287                objects,
1288                dtype_strings: caches.dtype_strings,
1289                type_strings: caches.type_strings,
1290                byte_order_strings: caches.byte_order_strings,
1291                filter_strings: caches.filter_strings,
1292                compression_strings: caches.compression_strings,
1293                encoding_strings: caches.encoding_strings,
1294                hash_type_strings: caches.hash_type_strings,
1295                hash_value_strings: caches.hash_value_strings,
1296            });
1297            unsafe {
1298                *out = Box::into_raw(msg);
1299            }
1300            TgmError::Ok
1301        }
1302        Err(e) => {
1303            set_last_error(&e.to_string());
1304            to_error_code(&e)
1305        }
1306    }
1307}
1308
1309/// Decode only the global metadata (no payload bytes are read).
1310#[unsafe(no_mangle)]
1311pub extern "C" fn tgm_decode_metadata(
1312    buf: *const u8,
1313    buf_len: usize,
1314    out: *mut *mut TgmMetadata,
1315) -> TgmError {
1316    if buf.is_null() || out.is_null() {
1317        set_last_error("null argument");
1318        return TgmError::InvalidArg;
1319    }
1320
1321    let data = unsafe { slice::from_raw_parts(buf, buf_len) };
1322
1323    match decode_metadata(data) {
1324        Ok(global_metadata) => {
1325            let m = Box::new(TgmMetadata {
1326                global_metadata,
1327                cache: std::cell::RefCell::new(BTreeMap::new()),
1328            });
1329            unsafe {
1330                *out = Box::into_raw(m);
1331            }
1332            TgmError::Ok
1333        }
1334        Err(e) => {
1335            set_last_error(&e.to_string());
1336            to_error_code(&e)
1337        }
1338    }
1339}
1340
1341/// Decode a single object by index.
1342///
1343/// On success, fills `out` with a `TgmMessage` handle containing exactly
1344/// one object (at index 0). The global metadata covers the whole message.
1345#[unsafe(no_mangle)]
1346pub extern "C" fn tgm_decode_object(
1347    buf: *const u8,
1348    buf_len: usize,
1349    index: usize,
1350    native_byte_order: i32,
1351    threads: u32,
1352    verify_hash: i32,
1353    out: *mut *mut TgmMessage,
1354) -> TgmError {
1355    if buf.is_null() || out.is_null() {
1356        set_last_error("null argument");
1357        return TgmError::InvalidArg;
1358    }
1359
1360    let data = unsafe { slice::from_raw_parts(buf, buf_len) };
1361    let options = DecodeOptions {
1362        native_byte_order: native_byte_order != 0,
1363        threads,
1364        verify_hash: verify_hash != 0,
1365        ..Default::default()
1366    };
1367
1368    match decode_object(data, index, &options) {
1369        Ok((global_metadata, descriptor, obj_bytes)) => {
1370            let objects = vec![(descriptor, obj_bytes)];
1371            let inline_hashes = extract_inline_hashes(data);
1372            let caches = build_message_caches(&objects, &inline_hashes);
1373            let msg = Box::new(TgmMessage {
1374                global_metadata,
1375                objects,
1376                dtype_strings: caches.dtype_strings,
1377                type_strings: caches.type_strings,
1378                byte_order_strings: caches.byte_order_strings,
1379                filter_strings: caches.filter_strings,
1380                compression_strings: caches.compression_strings,
1381                encoding_strings: caches.encoding_strings,
1382                hash_type_strings: caches.hash_type_strings,
1383                hash_value_strings: caches.hash_value_strings,
1384            });
1385            unsafe {
1386                *out = Box::into_raw(msg);
1387            }
1388            TgmError::Ok
1389        }
1390        Err(e) => {
1391            set_last_error(&e.to_string());
1392            to_error_code(&e)
1393        }
1394    }
1395}
1396
1397/// Decode partial ranges from a data object.
1398///
1399/// `ranges_offsets` / `ranges_counts`: parallel arrays of (element_offset, element_count).
1400/// `num_ranges`: length of both arrays.
1401/// `join`: when non-zero, concatenate all ranges into a single buffer in `out[0]`
1402///         and set `*out_count = 1`.  When zero (split mode), write one `TgmBytes`
1403///         per range into `out[0..num_ranges]` and set `*out_count = num_ranges`.
1404///         The caller must pre-allocate `out` with at least `num_ranges` entries
1405///         when `join == 0`, or 1 entry when `join != 0`.
1406/// `out_count`: filled with the number of buffers written to `out`.
1407///
1408/// Free each returned buffer with `tgm_bytes_free`.
1409#[unsafe(no_mangle)]
1410#[allow(clippy::too_many_arguments)]
1411pub extern "C" fn tgm_decode_range(
1412    buf: *const u8,
1413    buf_len: usize,
1414    object_index: usize,
1415    ranges_offsets: *const u64,
1416    ranges_counts: *const u64,
1417    num_ranges: usize,
1418    native_byte_order: i32,
1419    threads: u32,
1420    join: i32,
1421    out: *mut TgmBytes,
1422    out_count: *mut usize,
1423) -> TgmError {
1424    if buf.is_null() || out.is_null() || out_count.is_null() {
1425        set_last_error("null argument");
1426        return TgmError::InvalidArg;
1427    }
1428    if num_ranges > 0 && (ranges_offsets.is_null() || ranges_counts.is_null()) {
1429        set_last_error("null ranges_offsets or ranges_counts");
1430        return TgmError::InvalidArg;
1431    }
1432
1433    let data = unsafe { slice::from_raw_parts(buf, buf_len) };
1434    let options = DecodeOptions {
1435        native_byte_order: native_byte_order != 0,
1436        threads,
1437        ..Default::default()
1438    };
1439
1440    let ranges: Vec<(u64, u64)> = if num_ranges == 0 {
1441        vec![]
1442    } else {
1443        unsafe {
1444            let offsets = slice::from_raw_parts(ranges_offsets, num_ranges);
1445            let counts = slice::from_raw_parts(ranges_counts, num_ranges);
1446            offsets
1447                .iter()
1448                .zip(counts.iter())
1449                .map(|(&o, &c)| (o, c))
1450                .collect()
1451        }
1452    };
1453
1454    match decode_range(data, object_index, &ranges, &options) {
1455        Ok((_, parts)) => {
1456            if join != 0 {
1457                // Concatenate all parts into a single buffer.
1458                let joined: Vec<u8> = parts.into_iter().flatten().collect();
1459                let mut joined = joined.into_boxed_slice().into_vec();
1460                let result = TgmBytes {
1461                    data: joined.as_mut_ptr(),
1462                    len: joined.len(),
1463                };
1464                std::mem::forget(joined);
1465                unsafe {
1466                    *out = result;
1467                    *out_count = 1;
1468                }
1469            } else {
1470                // Write one TgmBytes per range.
1471                let n = parts.len();
1472                for (i, part) in parts.into_iter().enumerate() {
1473                    let mut part = part.into_boxed_slice().into_vec();
1474                    let result = TgmBytes {
1475                        data: part.as_mut_ptr(),
1476                        len: part.len(),
1477                    };
1478                    std::mem::forget(part);
1479                    unsafe {
1480                        *out.add(i) = result;
1481                    }
1482                }
1483                unsafe {
1484                    *out_count = n;
1485                }
1486            }
1487            TgmError::Ok
1488        }
1489        Err(e) => {
1490            set_last_error(&e.to_string());
1491            to_error_code(&e)
1492        }
1493    }
1494}
1495
1496// ---------------------------------------------------------------------------
1497// Scan
1498// ---------------------------------------------------------------------------
1499
1500/// Scan a buffer for message boundaries.
1501///
1502/// Returns a `TgmScanResult` handle. Access entries with `tgm_scan_count`
1503/// and `tgm_scan_entry`. Free with `tgm_scan_free`.
1504#[unsafe(no_mangle)]
1505pub extern "C" fn tgm_scan(
1506    buf: *const u8,
1507    buf_len: usize,
1508    out: *mut *mut TgmScanResult,
1509) -> TgmError {
1510    if buf.is_null() || out.is_null() {
1511        set_last_error("null argument");
1512        return TgmError::InvalidArg;
1513    }
1514
1515    let data = unsafe { slice::from_raw_parts(buf, buf_len) };
1516    let offsets = scan(data);
1517    let entries: Vec<TgmScanEntry> = offsets
1518        .into_iter()
1519        .map(|(offset, length)| TgmScanEntry { offset, length })
1520        .collect();
1521    let result = Box::new(TgmScanResult { entries });
1522    unsafe {
1523        *out = Box::into_raw(result);
1524    }
1525    TgmError::Ok
1526}
1527
1528/// # Safety: caller must pass valid, non-null pointer from tgm_scan.
1529unsafe fn as_scan(result: *const TgmScanResult) -> Option<&'static TgmScanResult> {
1530    unsafe {
1531        if result.is_null() {
1532            None
1533        } else {
1534            Some(&*result)
1535        }
1536    }
1537}
1538
1539/// # Safety: caller must pass valid, non-null pointer from tgm_decode*.
1540unsafe fn as_msg(msg: *const TgmMessage) -> Option<&'static TgmMessage> {
1541    unsafe { if msg.is_null() { None } else { Some(&*msg) } }
1542}
1543
1544/// Returns the number of messages found by `tgm_scan`.
1545#[unsafe(no_mangle)]
1546pub extern "C" fn tgm_scan_count(result: *const TgmScanResult) -> usize {
1547    unsafe { as_scan(result).map(|r| r.entries.len()).unwrap_or(0) }
1548}
1549
1550#[unsafe(no_mangle)]
1551pub extern "C" fn tgm_scan_entry(result: *const TgmScanResult, index: usize) -> TgmScanEntry {
1552    let fallback = TgmScanEntry {
1553        offset: usize::MAX,
1554        length: 0,
1555    };
1556    unsafe {
1557        match as_scan(result) {
1558            Some(r) => match r.entries.get(index) {
1559                Some(entry) => *entry,
1560                None => {
1561                    set_last_error(&format!(
1562                        "scan entry index {} out of range (count={})",
1563                        index,
1564                        r.entries.len()
1565                    ));
1566                    fallback
1567                }
1568            },
1569            None => {
1570                set_last_error("null scan result handle");
1571                fallback
1572            }
1573        }
1574    }
1575}
1576
1577/// Free a scan result handle.
1578#[unsafe(no_mangle)]
1579pub extern "C" fn tgm_scan_free(result: *mut TgmScanResult) {
1580    if !result.is_null() {
1581        unsafe {
1582            drop(Box::from_raw(result));
1583        }
1584    }
1585}
1586
1587// ---------------------------------------------------------------------------
1588// Message accessors
1589// ---------------------------------------------------------------------------
1590
1591/// Returns the wire format version the decoder read from the preamble.
1592///
1593/// v3 decoders reject any other version at preamble parse time, so
1594/// this always returns [`tensogram::WIRE_VERSION`] for any live
1595/// `TgmMessage` handle.  The CBOR metadata frame carries no
1596/// `version` key of its own (see `plans/WIRE_FORMAT.md` §6.1).
1597/// Returns `0` for a null handle.
1598#[unsafe(no_mangle)]
1599pub extern "C" fn tgm_message_version(msg: *const TgmMessage) -> u64 {
1600    unsafe {
1601        as_msg(msg)
1602            .map(|_| tensogram::WIRE_VERSION as u64)
1603            .unwrap_or(0)
1604    }
1605}
1606
1607/// Returns the number of decoded objects in this message handle.
1608/// For `tgm_decode` this equals the total object count; for
1609/// `tgm_decode_object` this is always 1.
1610#[unsafe(no_mangle)]
1611pub extern "C" fn tgm_message_num_objects(msg: *const TgmMessage) -> usize {
1612    unsafe { as_msg(msg).map(|m| m.objects.len()).unwrap_or(0) }
1613}
1614
1615/// Returns the number of decoded payload buffers.
1616/// Equivalent to `tgm_message_num_objects` — kept for ABI compatibility.
1617#[unsafe(no_mangle)]
1618pub extern "C" fn tgm_message_num_decoded(msg: *const TgmMessage) -> usize {
1619    unsafe { as_msg(msg).map(|m| m.objects.len()).unwrap_or(0) }
1620}
1621
1622/// Returns the number of dimensions for object at index.
1623#[unsafe(no_mangle)]
1624pub extern "C" fn tgm_object_ndim(msg: *const TgmMessage, index: usize) -> u64 {
1625    unsafe {
1626        as_msg(msg)
1627            .and_then(|m| m.objects.get(index))
1628            .map(|(desc, _)| desc.ndim)
1629            .unwrap_or(0)
1630    }
1631}
1632
1633/// Returns a pointer to the shape array. Length is `tgm_object_ndim()`.
1634/// The pointer is valid until the message is freed.
1635#[unsafe(no_mangle)]
1636pub extern "C" fn tgm_object_shape(msg: *const TgmMessage, index: usize) -> *const u64 {
1637    unsafe {
1638        as_msg(msg)
1639            .and_then(|m| m.objects.get(index))
1640            .map(|(desc, _)| desc.shape.as_ptr())
1641            .unwrap_or(ptr::null())
1642    }
1643}
1644
1645/// Returns a pointer to the strides array. Length is `tgm_object_ndim()`.
1646#[unsafe(no_mangle)]
1647pub extern "C" fn tgm_object_strides(msg: *const TgmMessage, index: usize) -> *const u64 {
1648    unsafe {
1649        as_msg(msg)
1650            .and_then(|m| m.objects.get(index))
1651            .map(|(desc, _)| desc.strides.as_ptr())
1652            .unwrap_or(ptr::null())
1653    }
1654}
1655
1656/// Returns the dtype as a null-terminated string (e.g. "float32").
1657/// The pointer is valid until the message is freed.
1658#[unsafe(no_mangle)]
1659pub extern "C" fn tgm_object_dtype(msg: *const TgmMessage, index: usize) -> *const c_char {
1660    unsafe {
1661        as_msg(msg)
1662            .and_then(|m| m.dtype_strings.get(index))
1663            .map(|s| s.as_ptr())
1664            .unwrap_or(ptr::null())
1665    }
1666}
1667
1668/// Returns a pointer to the decoded payload bytes for a decoded object.
1669/// `decoded_index` is the index into the decoded objects array (0 for the
1670/// first decoded object, regardless of the original object index).
1671/// `out_len` receives the byte length.
1672#[unsafe(no_mangle)]
1673pub extern "C" fn tgm_object_data(
1674    msg: *const TgmMessage,
1675    decoded_index: usize,
1676    out_len: *mut usize,
1677) -> *const u8 {
1678    unsafe {
1679        match as_msg(msg).and_then(|m| m.objects.get(decoded_index)) {
1680            Some((_, data)) => {
1681                if !out_len.is_null() {
1682                    *out_len = data.len();
1683                }
1684                data.as_ptr()
1685            }
1686            None => {
1687                if !out_len.is_null() {
1688                    *out_len = 0;
1689                }
1690                ptr::null()
1691            }
1692        }
1693    }
1694}
1695
1696/// Returns the encoding string for a data object descriptor (e.g. "none", "simple_packing").
1697/// The pointer is valid until the message is freed.
1698#[unsafe(no_mangle)]
1699pub extern "C" fn tgm_payload_encoding(msg: *const TgmMessage, index: usize) -> *const c_char {
1700    unsafe {
1701        as_msg(msg)
1702            .and_then(|m| m.encoding_strings.get(index))
1703            .map(|s| s.as_ptr())
1704            .unwrap_or(ptr::null())
1705    }
1706}
1707
1708/// Returns 1 if the i-th data object has a populated inline hash
1709/// slot, 0 otherwise.
1710///
1711/// In v3 the per-object hash lives in the frame footer's inline
1712/// slot (see `plans/WIRE_FORMAT.md` §2.4) rather than the CBOR
1713/// descriptor.  v3 hashing is a message-wide toggle: either every
1714/// frame's slot is populated (preamble flag `HASHES_PRESENT = 1`)
1715/// or every slot is zero (`HASHES_PRESENT = 0`).  This accessor
1716/// returns 1 when the i-th slot holds a non-zero xxh3-64 digest,
1717/// and 0 when the slot is zero (most commonly the whole-message
1718/// `HASHES_PRESENT = 0` case) or when the index is out of range.
1719///
1720/// A zero slot on a message that advertises `HASHES_PRESENT = 1`
1721/// is a structural anomaly (tamper or writer bug) — surface via
1722/// `tgm_validate` at the `checksum` / `integrity` level, which
1723/// will report a HashMismatch against the body's recomputed digest.
1724///
1725/// The matching hex digest is available via
1726/// [`tgm_object_hash_value`]; the algorithm tag (always
1727/// `"xxh3"` in v3) via [`tgm_object_hash_type`].
1728#[unsafe(no_mangle)]
1729pub extern "C" fn tgm_payload_has_hash(msg: *const TgmMessage, index: usize) -> i32 {
1730    unsafe {
1731        as_msg(msg)
1732            .and_then(|m| m.hash_value_strings.get(index))
1733            .map(|opt| opt.is_some() as i32)
1734            .unwrap_or(0)
1735    }
1736}
1737
1738/// Extract a metadata handle from a decoded message.
1739/// The metadata handle is independent — free it separately with `tgm_metadata_free`.
1740#[unsafe(no_mangle)]
1741pub extern "C" fn tgm_message_metadata(
1742    msg: *const TgmMessage,
1743    out: *mut *mut TgmMetadata,
1744) -> TgmError {
1745    if msg.is_null() || out.is_null() {
1746        set_last_error("null argument");
1747        return TgmError::InvalidArg;
1748    }
1749    let m = unsafe { &*msg };
1750    let meta = Box::new(TgmMetadata {
1751        global_metadata: m.global_metadata.clone(),
1752        cache: std::cell::RefCell::new(BTreeMap::new()),
1753    });
1754    unsafe {
1755        *out = Box::into_raw(meta);
1756    }
1757    TgmError::Ok
1758}
1759
1760/// Returns the object type string (e.g. "ndarray"). Valid until message freed.
1761#[unsafe(no_mangle)]
1762pub extern "C" fn tgm_object_type(msg: *const TgmMessage, index: usize) -> *const c_char {
1763    unsafe {
1764        as_msg(msg)
1765            .and_then(|m| m.type_strings.get(index))
1766            .map(|s| s.as_ptr())
1767            .unwrap_or(ptr::null())
1768    }
1769}
1770
1771/// Returns the byte order string ("big" or "little"). Valid until message freed.
1772#[unsafe(no_mangle)]
1773pub extern "C" fn tgm_object_byte_order(msg: *const TgmMessage, index: usize) -> *const c_char {
1774    unsafe {
1775        as_msg(msg)
1776            .and_then(|m| m.byte_order_strings.get(index))
1777            .map(|s| s.as_ptr())
1778            .unwrap_or(ptr::null())
1779    }
1780}
1781
1782/// Returns the filter string (e.g. "none", "shuffle"). Valid until message freed.
1783#[unsafe(no_mangle)]
1784pub extern "C" fn tgm_object_filter(msg: *const TgmMessage, index: usize) -> *const c_char {
1785    unsafe {
1786        as_msg(msg)
1787            .and_then(|m| m.filter_strings.get(index))
1788            .map(|s| s.as_ptr())
1789            .unwrap_or(ptr::null())
1790    }
1791}
1792
1793/// Returns the compression string (e.g. "none", "zstd"). Valid until message freed.
1794#[unsafe(no_mangle)]
1795pub extern "C" fn tgm_object_compression(msg: *const TgmMessage, index: usize) -> *const c_char {
1796    unsafe {
1797        as_msg(msg)
1798            .and_then(|m| m.compression_strings.get(index))
1799            .map(|s| s.as_ptr())
1800            .unwrap_or(ptr::null())
1801    }
1802}
1803
1804/// Returns the hash type string ("xxh3") or NULL if no hash. Valid until message freed.
1805#[unsafe(no_mangle)]
1806pub extern "C" fn tgm_object_hash_type(msg: *const TgmMessage, index: usize) -> *const c_char {
1807    unsafe {
1808        as_msg(msg)
1809            .and_then(|m| m.hash_type_strings.get(index))
1810            .and_then(|opt| opt.as_ref())
1811            .map(|s| s.as_ptr())
1812            .unwrap_or(ptr::null())
1813    }
1814}
1815
1816/// Returns the hash value hex string or NULL if no hash. Valid until message freed.
1817#[unsafe(no_mangle)]
1818pub extern "C" fn tgm_object_hash_value(msg: *const TgmMessage, index: usize) -> *const c_char {
1819    unsafe {
1820        as_msg(msg)
1821            .and_then(|m| m.hash_value_strings.get(index))
1822            .and_then(|opt| opt.as_ref())
1823            .map(|s| s.as_ptr())
1824            .unwrap_or(ptr::null())
1825    }
1826}
1827
1828/// Free a decoded message handle.
1829#[unsafe(no_mangle)]
1830pub extern "C" fn tgm_message_free(msg: *mut TgmMessage) {
1831    if !msg.is_null() {
1832        unsafe {
1833            drop(Box::from_raw(msg));
1834        }
1835    }
1836}
1837
1838// ---------------------------------------------------------------------------
1839// Metadata accessors
1840// ---------------------------------------------------------------------------
1841
1842/// Returns the wire format version.
1843///
1844/// Sourced from [`tensogram::WIRE_VERSION`] since v3 decoders reject any
1845/// other version at preamble parse time.  The CBOR metadata frame
1846/// carries no `version` key of its own (see
1847/// `plans/WIRE_FORMAT.md` §6.1).  Returns `0` for a null handle.
1848#[unsafe(no_mangle)]
1849pub extern "C" fn tgm_metadata_version(meta: *const TgmMetadata) -> u64 {
1850    if meta.is_null() {
1851        return 0;
1852    }
1853    tensogram::WIRE_VERSION as u64
1854}
1855
1856/// Returns the number of objects described in the global metadata.
1857///
1858/// Returns the length of the `base` array, which has one entry per data object.
1859#[unsafe(no_mangle)]
1860pub extern "C" fn tgm_metadata_num_objects(meta: *const TgmMetadata) -> usize {
1861    if meta.is_null() {
1862        return 0;
1863    }
1864    unsafe { (*meta).global_metadata.base.len() }
1865}
1866
1867/// Look up a string value by dot-notation key (e.g. "mars.class").
1868///
1869/// Text is returned as-is; integer / float / bool leaves are coerced to their
1870/// string form.  Returns NULL if the key is not found, the value is a container
1871/// (map / array), null, or bytes, or the value contains an interior NUL byte
1872/// (not representable as a C string).
1873/// The pointer is valid until the metadata handle is freed.
1874///
1875/// The pseudo-key `"version"` is special-cased: it returns the wire-format
1876/// version from the message preamble (see [`tgm_metadata_version`]), not a
1877/// CBOR `version` field.
1878#[unsafe(no_mangle)]
1879pub extern "C" fn tgm_metadata_get_string(
1880    meta: *const TgmMetadata,
1881    key: *const c_char,
1882) -> *const c_char {
1883    if meta.is_null() || key.is_null() {
1884        return ptr::null();
1885    }
1886
1887    let key_str = match unsafe { CStr::from_ptr(key) }.to_str() {
1888        Ok(s) => s,
1889        Err(_) => return ptr::null(),
1890    };
1891
1892    let m = unsafe { &(*meta) };
1893    match lookup_string_key(&m.global_metadata, key_str) {
1894        Some(s) => cache_cstr(m, key_str.to_string(), || s),
1895        None => ptr::null(),
1896    }
1897}
1898
1899/// Look up an integer value by dot-notation key.
1900/// Returns `default_val` if the key is not found or is not an integer.
1901///
1902/// The pseudo-key `"version"` is special-cased: it returns the wire-format
1903/// version from the message preamble (see [`tgm_metadata_version`]), not a
1904/// CBOR `version` field.
1905#[unsafe(no_mangle)]
1906pub extern "C" fn tgm_metadata_get_int(
1907    meta: *const TgmMetadata,
1908    key: *const c_char,
1909    default_val: i64,
1910) -> i64 {
1911    if meta.is_null() || key.is_null() {
1912        return default_val;
1913    }
1914
1915    let key_str = match unsafe { CStr::from_ptr(key) }.to_str() {
1916        Ok(s) => s,
1917        Err(_) => return default_val,
1918    };
1919
1920    let m = unsafe { &(*meta) };
1921    lookup_int_key(&m.global_metadata, key_str).unwrap_or(default_val)
1922}
1923
1924/// Look up a float value by dot-notation key.  Integer leaves are widened to
1925/// `f64`.  Returns `default_val` if the key is not found or the value is not
1926/// numeric.
1927#[unsafe(no_mangle)]
1928pub extern "C" fn tgm_metadata_get_float(
1929    meta: *const TgmMetadata,
1930    key: *const c_char,
1931    default_val: f64,
1932) -> f64 {
1933    if meta.is_null() || key.is_null() {
1934        return default_val;
1935    }
1936
1937    let key_str = match unsafe { CStr::from_ptr(key) }.to_str() {
1938        Ok(s) => s,
1939        Err(_) => return default_val,
1940    };
1941
1942    let m = unsafe { &(*meta) };
1943    lookup_float_key(&m.global_metadata, key_str).unwrap_or(default_val)
1944}
1945
1946// ---------------------------------------------------------------------------
1947// Per-object metadata accessors
1948//
1949// The message-level getters above search `base` and return the first match,
1950// so `base[i]` for a multi-object message is unreachable through them.  These
1951// `_at` variants scope the lookup to a single object — the C equivalent of
1952// Rust's `meta.base[obj_index].get(key)` — so a caller can walk every object.
1953// ---------------------------------------------------------------------------
1954
1955/// Look up a string value in object `obj_index`'s metadata by dot-notation key
1956/// (e.g. `"shortName"`, `"mars.class"`, `"geometry.gridType"`).
1957///
1958/// Scoped to `base[obj_index]` only — no cross-object first-match, no `extra`
1959/// fallback, and `_reserved_` is skipped at the first level (use the object
1960/// accessors for the tensor shape/dtype).  Integer / float / bool leaves are
1961/// coerced to their string form.
1962///
1963/// Returns NULL if the handle is null, `obj_index` is out of range, the key is
1964/// absent, the value is a container (map / array), null, or bytes, or the value
1965/// contains an interior NUL byte (not representable as a C string).  The pointer
1966/// is valid until the metadata handle is freed.
1967#[unsafe(no_mangle)]
1968pub extern "C" fn tgm_metadata_get_string_at(
1969    meta: *const TgmMetadata,
1970    obj_index: usize,
1971    key: *const c_char,
1972) -> *const c_char {
1973    if meta.is_null() || key.is_null() {
1974        return ptr::null();
1975    }
1976    let key_str = match unsafe { CStr::from_ptr(key) }.to_str() {
1977        Ok(s) => s,
1978        Err(_) => return ptr::null(),
1979    };
1980    let m = unsafe { &(*meta) };
1981    let Some(entry) = m.global_metadata.base.get(obj_index) else {
1982        return ptr::null();
1983    };
1984    match lookup_cbor_in_object(entry, key_str).and_then(cbor_as_string) {
1985        Some(s) => cache_cstr(m, format!("{obj_index}\0{key_str}"), || s),
1986        None => ptr::null(),
1987    }
1988}
1989
1990/// Look up an integer value in object `obj_index`'s metadata by dot-notation
1991/// key.  Returns `default_val` if the handle is null, `obj_index` is out of
1992/// range, the key is absent, or the value is not an integer.  See
1993/// [`tgm_metadata_get_string_at`] for scoping rules.
1994#[unsafe(no_mangle)]
1995pub extern "C" fn tgm_metadata_get_int_at(
1996    meta: *const TgmMetadata,
1997    obj_index: usize,
1998    key: *const c_char,
1999    default_val: i64,
2000) -> i64 {
2001    if meta.is_null() || key.is_null() {
2002        return default_val;
2003    }
2004    let key_str = match unsafe { CStr::from_ptr(key) }.to_str() {
2005        Ok(s) => s,
2006        Err(_) => return default_val,
2007    };
2008    let m = unsafe { &(*meta) };
2009    match m.global_metadata.base.get(obj_index) {
2010        Some(entry) => lookup_cbor_in_object(entry, key_str)
2011            .and_then(cbor_as_i64)
2012            .unwrap_or(default_val),
2013        None => default_val,
2014    }
2015}
2016
2017/// Look up a float value in object `obj_index`'s metadata by dot-notation key.
2018/// Returns `default_val` if the handle is null, `obj_index` is out of range,
2019/// the key is absent, or the value is not numeric.  Integer leaves are widened
2020/// to `f64`.  See [`tgm_metadata_get_string_at`] for scoping rules.
2021#[unsafe(no_mangle)]
2022pub extern "C" fn tgm_metadata_get_float_at(
2023    meta: *const TgmMetadata,
2024    obj_index: usize,
2025    key: *const c_char,
2026    default_val: f64,
2027) -> f64 {
2028    if meta.is_null() || key.is_null() {
2029        return default_val;
2030    }
2031    let key_str = match unsafe { CStr::from_ptr(key) }.to_str() {
2032        Ok(s) => s,
2033        Err(_) => return default_val,
2034    };
2035    let m = unsafe { &(*meta) };
2036    match m.global_metadata.base.get(obj_index) {
2037        Some(entry) => lookup_cbor_in_object(entry, key_str)
2038            .and_then(cbor_as_f64)
2039            .unwrap_or(default_val),
2040        None => default_val,
2041    }
2042}
2043
2044/// Serialise object `obj_index`'s full metadata (`base[obj_index]`) to a JSON
2045/// object string — including any `_reserved_.tensor` descriptor.
2046///
2047/// This is the enumerate-everything companion to the typed getters: a caller
2048/// that does not know the key set ahead of time (e.g. a metadata viewer) can
2049/// display or parse the whole object without walking CBOR itself.  Returns
2050/// NULL for a null handle or an out-of-range `obj_index`.  The pointer is valid
2051/// until the metadata handle is freed.
2052#[unsafe(no_mangle)]
2053pub extern "C" fn tgm_metadata_object_to_json(
2054    meta: *const TgmMetadata,
2055    obj_index: usize,
2056) -> *const c_char {
2057    if meta.is_null() {
2058        return ptr::null();
2059    }
2060    let m = unsafe { &(*meta) };
2061    let Some(entry) = m.global_metadata.base.get(obj_index) else {
2062        return ptr::null();
2063    };
2064    cache_cstr(m, format!("\0json\0{obj_index}"), || {
2065        cbor_map_to_json(entry).to_string()
2066    })
2067}
2068
2069/// Serialise the whole global metadata to a JSON object string, shaped
2070/// `{ "base": [...], "_reserved_": {...}, "_extra_": {...} }` (empty sections
2071/// omitted).  Returns NULL for a null handle; the pointer is valid until the
2072/// metadata handle is freed.
2073#[unsafe(no_mangle)]
2074pub extern "C" fn tgm_metadata_to_json(meta: *const TgmMetadata) -> *const c_char {
2075    if meta.is_null() {
2076        return ptr::null();
2077    }
2078    let m = unsafe { &(*meta) };
2079    cache_cstr(m, "\0json".to_string(), || {
2080        let gm = &m.global_metadata;
2081        let mut root = serde_json::Map::new();
2082        if !gm.base.is_empty() {
2083            let base: Vec<serde_json::Value> = gm.base.iter().map(cbor_map_to_json).collect();
2084            root.insert("base".to_string(), serde_json::Value::Array(base));
2085        }
2086        if !gm.reserved.is_empty() {
2087            root.insert("_reserved_".to_string(), cbor_map_to_json(&gm.reserved));
2088        }
2089        if !gm.extra.is_empty() {
2090            root.insert("_extra_".to_string(), cbor_map_to_json(&gm.extra));
2091        }
2092        serde_json::Value::Object(root).to_string()
2093    })
2094}
2095
2096/// Free a metadata handle.
2097#[unsafe(no_mangle)]
2098pub extern "C" fn tgm_metadata_free(meta: *mut TgmMetadata) {
2099    if !meta.is_null() {
2100        unsafe {
2101            drop(Box::from_raw(meta));
2102        }
2103    }
2104}
2105
2106// ---------------------------------------------------------------------------
2107// File API
2108// ---------------------------------------------------------------------------
2109
2110/// Open an existing Tensogram file for reading.
2111#[unsafe(no_mangle)]
2112pub extern "C" fn tgm_file_open(path: *const c_char, out: *mut *mut TgmFile) -> TgmError {
2113    if path.is_null() || out.is_null() {
2114        set_last_error("null argument");
2115        return TgmError::InvalidArg;
2116    }
2117
2118    let path_str = match unsafe { CStr::from_ptr(path) }.to_str() {
2119        Ok(s) => s,
2120        Err(e) => {
2121            set_last_error(&format!("invalid UTF-8 in path: {e}"));
2122            return TgmError::InvalidArg;
2123        }
2124    };
2125
2126    match TensogramFile::open(path_str) {
2127        Ok(file) => {
2128            let path_string = CString::new(path_str).unwrap_or_default();
2129            let handle = Box::new(TgmFile { file, path_string });
2130            unsafe {
2131                *out = Box::into_raw(handle);
2132            }
2133            TgmError::Ok
2134        }
2135        Err(e) => {
2136            set_last_error(&e.to_string());
2137            to_error_code(&e)
2138        }
2139    }
2140}
2141
2142/// Create a new Tensogram file for writing.
2143#[unsafe(no_mangle)]
2144pub extern "C" fn tgm_file_create(path: *const c_char, out: *mut *mut TgmFile) -> TgmError {
2145    if path.is_null() || out.is_null() {
2146        set_last_error("null argument");
2147        return TgmError::InvalidArg;
2148    }
2149
2150    let path_str = match unsafe { CStr::from_ptr(path) }.to_str() {
2151        Ok(s) => s,
2152        Err(e) => {
2153            set_last_error(&format!("invalid UTF-8 in path: {e}"));
2154            return TgmError::InvalidArg;
2155        }
2156    };
2157
2158    match TensogramFile::create(path_str) {
2159        Ok(file) => {
2160            let path_string = CString::new(path_str).unwrap_or_default();
2161            let handle = Box::new(TgmFile { file, path_string });
2162            unsafe {
2163                *out = Box::into_raw(handle);
2164            }
2165            TgmError::Ok
2166        }
2167        Err(e) => {
2168            set_last_error(&e.to_string());
2169            to_error_code(&e)
2170        }
2171    }
2172}
2173
2174/// Count messages in the file (may trigger lazy scan).
2175#[unsafe(no_mangle)]
2176pub extern "C" fn tgm_file_message_count(file: *mut TgmFile, out_count: *mut usize) -> TgmError {
2177    if file.is_null() || out_count.is_null() {
2178        set_last_error("null argument");
2179        return TgmError::InvalidArg;
2180    }
2181
2182    let f = unsafe { &(*file).file };
2183    match f.message_count() {
2184        Ok(count) => {
2185            unsafe {
2186                *out_count = count;
2187            }
2188            TgmError::Ok
2189        }
2190        Err(e) => {
2191            set_last_error(&e.to_string());
2192            to_error_code(&e)
2193        }
2194    }
2195}
2196
2197/// Decode message at `index` from the file.
2198/// On success fills `out` with a `TgmMessage` handle.
2199#[unsafe(no_mangle)]
2200pub extern "C" fn tgm_file_decode_message(
2201    file: *mut TgmFile,
2202    index: usize,
2203    native_byte_order: i32,
2204    threads: u32,
2205    verify_hash: i32,
2206    out: *mut *mut TgmMessage,
2207) -> TgmError {
2208    if file.is_null() || out.is_null() {
2209        set_last_error("null argument");
2210        return TgmError::InvalidArg;
2211    }
2212
2213    let f = unsafe { &(*file).file };
2214    let options = DecodeOptions {
2215        native_byte_order: native_byte_order != 0,
2216        threads,
2217        verify_hash: verify_hash != 0,
2218        ..Default::default()
2219    };
2220
2221    match f.decode_message(index, &options) {
2222        Ok((global_metadata, objects)) => {
2223            // Inline hashes: re-read the raw message bytes and run
2224            // them through the cheap frame-header walker.  This
2225            // costs one extra message read (typically memory-
2226            // mapped) but gives FFI file-path callers parity with
2227            // the buffer path's hash accessors.  Silent fallback
2228            // to empty on read error — `decode_message` above
2229            // already succeeded so this branch is defensive.
2230            let inline_hashes = f
2231                .read_message(index)
2232                .ok()
2233                .and_then(|bytes| tensogram::framing::data_object_inline_hashes(&bytes).ok())
2234                .unwrap_or_default();
2235            let caches = build_message_caches(&objects, &inline_hashes);
2236            let msg = Box::new(TgmMessage {
2237                global_metadata,
2238                objects,
2239                dtype_strings: caches.dtype_strings,
2240                type_strings: caches.type_strings,
2241                byte_order_strings: caches.byte_order_strings,
2242                filter_strings: caches.filter_strings,
2243                compression_strings: caches.compression_strings,
2244                encoding_strings: caches.encoding_strings,
2245                hash_type_strings: caches.hash_type_strings,
2246                hash_value_strings: caches.hash_value_strings,
2247            });
2248            unsafe {
2249                *out = Box::into_raw(msg);
2250            }
2251            TgmError::Ok
2252        }
2253        Err(e) => {
2254            set_last_error(&e.to_string());
2255            to_error_code(&e)
2256        }
2257    }
2258}
2259
2260/// Read raw message bytes at `index`.
2261/// On success fills `out` with a `TgmBytes` buffer.
2262#[unsafe(no_mangle)]
2263pub extern "C" fn tgm_file_read_message(
2264    file: *mut TgmFile,
2265    index: usize,
2266    out: *mut TgmBytes,
2267) -> TgmError {
2268    if file.is_null() || out.is_null() {
2269        set_last_error("null argument");
2270        return TgmError::InvalidArg;
2271    }
2272
2273    let f = unsafe { &(*file).file };
2274
2275    match f.read_message(index) {
2276        Ok(bytes) => {
2277            // Rebuild via boxed slice to guarantee capacity == len for tgm_bytes_free.
2278            let mut bytes = bytes.into_boxed_slice().into_vec();
2279            let result = TgmBytes {
2280                data: bytes.as_mut_ptr(),
2281                len: bytes.len(),
2282            };
2283            std::mem::forget(bytes);
2284            unsafe {
2285                *out = result;
2286            }
2287            TgmError::Ok
2288        }
2289        Err(e) => {
2290            set_last_error(&e.to_string());
2291            to_error_code(&e)
2292        }
2293    }
2294}
2295
2296/// Append raw message bytes to the file.
2297#[unsafe(no_mangle)]
2298pub extern "C" fn tgm_file_append_raw(
2299    file: *mut TgmFile,
2300    buf: *const u8,
2301    buf_len: usize,
2302) -> TgmError {
2303    if file.is_null() || buf.is_null() {
2304        set_last_error("null argument");
2305        return TgmError::InvalidArg;
2306    }
2307
2308    let data = unsafe { slice::from_raw_parts(buf, buf_len) };
2309    let f = unsafe { &mut (*file).file };
2310
2311    // Write raw bytes using std::fs
2312    use std::io::Write;
2313    let path = match f.path() {
2314        Some(p) => p.to_path_buf(),
2315        None => {
2316            set_last_error("append_raw not supported on remote files");
2317            return TgmError::Remote;
2318        }
2319    };
2320    let result = std::fs::OpenOptions::new()
2321        .create(true)
2322        .append(true)
2323        .open(&path)
2324        .and_then(|mut fh| fh.write_all(data));
2325
2326    match result {
2327        Ok(()) => {
2328            f.invalidate_offsets();
2329            TgmError::Ok
2330        }
2331        Err(e) => {
2332            set_last_error(&e.to_string());
2333            TgmError::Io
2334        }
2335    }
2336}
2337
2338/// Returns the file path as a null-terminated string.
2339/// The pointer is valid until the file handle is closed.
2340#[unsafe(no_mangle)]
2341pub extern "C" fn tgm_file_path(file: *const TgmFile) -> *const c_char {
2342    if file.is_null() {
2343        return ptr::null();
2344    }
2345    unsafe { (*file).path_string.as_ptr() }
2346}
2347
2348/// Encode and append a message to the file.
2349/// Same JSON schema as `tgm_encode` for `metadata_json`.
2350///
2351/// Non-finite-value rejection is on by default in 0.17+; see `tgm_encode`.
2352#[unsafe(no_mangle)]
2353pub extern "C" fn tgm_file_append(
2354    file: *mut TgmFile,
2355    metadata_json: *const c_char,
2356    data_ptrs: *const *const u8,
2357    data_lens: *const usize,
2358    num_objects: usize,
2359    hash_algo: *const c_char,
2360    threads: u32,
2361) -> TgmError {
2362    if file.is_null() || metadata_json.is_null() {
2363        set_last_error("null argument");
2364        return TgmError::InvalidArg;
2365    }
2366
2367    let json_str = match unsafe { CStr::from_ptr(metadata_json) }.to_str() {
2368        Ok(s) => s,
2369        Err(e) => {
2370            set_last_error(&format!("invalid UTF-8 in metadata_json: {e}"));
2371            return TgmError::InvalidArg;
2372        }
2373    };
2374
2375    let parsed = match unsafe {
2376        parse_encode_args(
2377            json_str,
2378            data_ptrs,
2379            data_lens,
2380            num_objects,
2381            hash_algo,
2382            threads,
2383        )
2384    } {
2385        Ok(p) => p,
2386        Err((code, msg)) => {
2387            set_last_error(&msg);
2388            return code;
2389        }
2390    };
2391
2392    let pairs: Vec<(&DataObjectDescriptor, &[u8])> = parsed
2393        .descriptors
2394        .iter()
2395        .zip(parsed.data_slices.iter())
2396        .map(|(d, s)| (d, *s))
2397        .collect();
2398
2399    let f = unsafe { &mut (*file).file };
2400    match f.append(&parsed.global_metadata, &pairs, &parsed.options) {
2401        Ok(()) => TgmError::Ok,
2402        Err(e) => {
2403            set_last_error(&e.to_string());
2404            to_error_code(&e)
2405        }
2406    }
2407}
2408
2409/// Close a file handle and release resources.
2410#[unsafe(no_mangle)]
2411pub extern "C" fn tgm_file_close(file: *mut TgmFile) {
2412    if !file.is_null() {
2413        unsafe {
2414            drop(Box::from_raw(file));
2415        }
2416    }
2417}
2418
2419// ---------------------------------------------------------------------------
2420// Metadata key lookup helpers
2421// ---------------------------------------------------------------------------
2422
2423/// Look up a CBOR value by dot-notation key with arbitrary nesting depth.
2424///
2425/// Supports `"key"`, `"ns.field"`, `"grib.geography.Ni"`, etc.
2426/// Search order: `base[i]` (skip `_reserved_`, first match) → `extra`.
2427fn lookup_cbor_value<'a>(
2428    global_metadata: &'a GlobalMetadata,
2429    key: &str,
2430) -> Option<&'a ciborium::Value> {
2431    if key.is_empty() {
2432        return None;
2433    }
2434    let parts: Vec<&str> = key.split('.').collect();
2435
2436    if parts.is_empty() || parts[0].is_empty() {
2437        return None;
2438    }
2439    if parts[0] == "version" {
2440        return None; // use tgm_metadata_version instead
2441    }
2442
2443    // Explicit _extra_ or extra prefix targets the extra map directly
2444    if parts[0] == "_extra_" || parts[0] == "extra" {
2445        if parts.len() > 1 {
2446            return resolve_in_btree(&global_metadata.extra, &parts[1..]);
2447        }
2448        return None;
2449    }
2450
2451    // Search base entries (skip _reserved_ key within each entry)
2452    for entry in &global_metadata.base {
2453        if let Some(val) = resolve_in_btree_skip_reserved(entry, &parts) {
2454            return Some(val);
2455        }
2456    }
2457    // Fall back to extra
2458    resolve_in_btree(&global_metadata.extra, &parts)
2459}
2460
2461/// Walk a dot-path in a BTreeMap, skipping `_reserved_` keys at the first level.
2462fn resolve_in_btree_skip_reserved<'a>(
2463    map: &'a BTreeMap<String, ciborium::Value>,
2464    parts: &[&str],
2465) -> Option<&'a ciborium::Value> {
2466    let (first, rest) = parts.split_first()?;
2467    if *first == RESERVED_KEY {
2468        return None;
2469    }
2470    let value = map.get(*first)?;
2471    resolve_cbor_path(value, rest)
2472}
2473
2474/// Walk a dot-path in a BTreeMap (no _reserved_ filtering).
2475fn resolve_in_btree<'a>(
2476    map: &'a BTreeMap<String, ciborium::Value>,
2477    parts: &[&str],
2478) -> Option<&'a ciborium::Value> {
2479    let (first, rest) = parts.split_first()?;
2480    let value = map.get(*first)?;
2481    resolve_cbor_path(value, rest)
2482}
2483
2484/// Recursively walk remaining path segments into a CBOR value.
2485///
2486/// When no segments remain, returns the current value.
2487/// When segments remain, the current value must be a `Map` to navigate further.
2488fn resolve_cbor_path<'a>(
2489    value: &'a ciborium::Value,
2490    remaining: &[&str],
2491) -> Option<&'a ciborium::Value> {
2492    if remaining.is_empty() {
2493        return Some(value);
2494    }
2495    if let ciborium::Value::Map(entries) = value {
2496        for (k, v) in entries {
2497            if matches!(k, ciborium::Value::Text(s) if s == remaining[0]) {
2498                return resolve_cbor_path(v, &remaining[1..]);
2499            }
2500        }
2501    }
2502    None
2503}
2504
2505/// Navigate a dot-path within a single `base[i]` entry.
2506///
2507/// Scoped to exactly this object — no cross-object first-match and no
2508/// `extra` fallback (unlike [`lookup_cbor_value`]).  `_reserved_` is skipped
2509/// at the first level, matching the message-level getters (the object's
2510/// tensor descriptor is reached via the object accessors instead).
2511fn lookup_cbor_in_object<'a>(
2512    entry: &'a BTreeMap<String, ciborium::Value>,
2513    key: &str,
2514) -> Option<&'a ciborium::Value> {
2515    if key.is_empty() {
2516        return None;
2517    }
2518    let parts: Vec<&str> = key.split('.').collect();
2519    if parts.is_empty() || parts[0].is_empty() {
2520        return None;
2521    }
2522    resolve_in_btree_skip_reserved(entry, &parts)
2523}
2524
2525/// Coerce a CBOR value to a display string (text / number / bool).
2526fn cbor_as_string(v: &ciborium::Value) -> Option<String> {
2527    match v {
2528        ciborium::Value::Text(s) => Some(s.clone()),
2529        ciborium::Value::Integer(i) => {
2530            let n: i128 = (*i).into();
2531            Some(n.to_string())
2532        }
2533        ciborium::Value::Float(f) => Some(f.to_string()),
2534        ciborium::Value::Bool(b) => Some(b.to_string()),
2535        _ => None,
2536    }
2537}
2538
2539/// Coerce a CBOR value to `i64` (integers only).
2540fn cbor_as_i64(v: &ciborium::Value) -> Option<i64> {
2541    match v {
2542        ciborium::Value::Integer(i) => {
2543            let n: i128 = (*i).into();
2544            i64::try_from(n).ok()
2545        }
2546        _ => None,
2547    }
2548}
2549
2550/// Coerce a CBOR value to `f64` (float, or integer widened).
2551fn cbor_as_f64(v: &ciborium::Value) -> Option<f64> {
2552    match v {
2553        ciborium::Value::Float(f) => Some(*f),
2554        ciborium::Value::Integer(i) => {
2555            let n: i128 = (*i).into();
2556            // i128 → f64 may lose precision for very large integers, but this
2557            // is the expected behavior for a float accessor on an integer value.
2558            Some(n as f64)
2559        }
2560        _ => None,
2561    }
2562}
2563
2564fn lookup_string_key(global_metadata: &GlobalMetadata, key: &str) -> Option<String> {
2565    if key.is_empty() {
2566        return None;
2567    }
2568    // `version` is a pseudo-key — the wire-format version lives in the
2569    // preamble (see `plans/WIRE_FORMAT.md` §3), not in the CBOR metadata
2570    // frame.  Return the constant so FFI tooling that queries the key
2571    // keeps seeing `"3"`.
2572    if key == "version" {
2573        return Some(tensogram::WIRE_VERSION.to_string());
2574    }
2575
2576    lookup_cbor_value(global_metadata, key).and_then(cbor_as_string)
2577}
2578
2579fn lookup_int_key(global_metadata: &GlobalMetadata, key: &str) -> Option<i64> {
2580    // `version` pseudo-key — see `lookup_string_key` for rationale.
2581    if key == "version" {
2582        return Some(tensogram::WIRE_VERSION as i64);
2583    }
2584
2585    lookup_cbor_value(global_metadata, key).and_then(cbor_as_i64)
2586}
2587
2588fn lookup_float_key(global_metadata: &GlobalMetadata, key: &str) -> Option<f64> {
2589    lookup_cbor_value(global_metadata, key).and_then(cbor_as_f64)
2590}
2591
2592/// Convert a CBOR value to a `serde_json::Value` for the metadata JSON export.
2593///
2594/// Faithful for the value kinds that appear in tensogram metadata; the
2595/// awkward cases are handled explicitly:
2596/// - integers outside `i64`/`u64` range become strings (JSON has no i128),
2597/// - non-finite floats become `null` (JSON has no NaN/Inf),
2598/// - byte strings become lowercase hex,
2599/// - non-text map keys become their debug form (metadata keys are text).
2600fn cbor_to_json(v: &ciborium::Value) -> serde_json::Value {
2601    use serde_json::Value as J;
2602    match v {
2603        ciborium::Value::Null => J::Null,
2604        ciborium::Value::Bool(b) => J::Bool(*b),
2605        ciborium::Value::Integer(i) => {
2606            let n: i128 = (*i).into();
2607            if let Ok(x) = i64::try_from(n) {
2608                J::from(x)
2609            } else if let Ok(x) = u64::try_from(n) {
2610                J::from(x)
2611            } else {
2612                J::String(n.to_string())
2613            }
2614        }
2615        ciborium::Value::Float(f) => serde_json::Number::from_f64(*f).map_or(J::Null, J::Number),
2616        ciborium::Value::Text(s) => J::String(s.clone()),
2617        ciborium::Value::Bytes(b) => {
2618            // Single allocation — `format!` per byte would allocate a String each.
2619            use std::fmt::Write as _;
2620            let mut hex = String::with_capacity(b.len() * 2);
2621            for byte in b {
2622                let _ = write!(hex, "{byte:02x}");
2623            }
2624            J::String(hex)
2625        }
2626        ciborium::Value::Array(a) => J::Array(a.iter().map(cbor_to_json).collect()),
2627        ciborium::Value::Map(m) => {
2628            let mut obj = serde_json::Map::with_capacity(m.len());
2629            for (k, val) in m {
2630                let key = match k {
2631                    ciborium::Value::Text(s) => s.clone(),
2632                    other => format!("{other:?}"),
2633                };
2634                obj.insert(key, cbor_to_json(val));
2635            }
2636            J::Object(obj)
2637        }
2638        // Tag / other exotic kinds are not produced by the metadata encoder.
2639        _ => J::Null,
2640    }
2641}
2642
2643/// Convert a metadata sub-map (`base[i]`, `_reserved_`, `_extra_`) to a JSON
2644/// object.  Their keys are always text, unlike a nested `ciborium::Value::Map`,
2645/// so this is the simple companion to [`cbor_to_json`] used by the exporters.
2646fn cbor_map_to_json(map: &BTreeMap<String, ciborium::Value>) -> serde_json::Value {
2647    serde_json::Value::Object(
2648        map.iter()
2649            .map(|(k, v)| (k.clone(), cbor_to_json(v)))
2650            .collect(),
2651    )
2652}
2653
2654/// Cache the string produced by `build` under `key` and return a borrowed
2655/// pointer valid until the handle is freed, or NULL if the string cannot be a
2656/// C string (it contains an interior NUL byte).
2657///
2658/// This is how every string-returning metadata accessor hands ownership to C
2659/// without a separate free: the `CString` lives in the handle, so the returned
2660/// pointer outlives the transient `RefMut`.  `build` runs **only on a cache
2661/// miss**, so a repeat call (e.g. re-fetching an object's JSON) returns the
2662/// cached pointer without re-serialising.
2663///
2664/// A value with an interior NUL is not representable as a C string, so it
2665/// yields NULL (the accessors' "no usable value" signal) rather than a
2666/// silently-truncated empty string.  (Serialised JSON never hits this: serde
2667/// escapes NUL as `\u0000`.)
2668///
2669/// Cache keys are namespaced to avoid collision between the accessors that
2670/// share the map: message-level `get_string` uses the raw (NUL-free) C key;
2671/// `get_string_at` uses `"{obj_index}\0{key}"`; the JSON exporters use
2672/// `"\0json"` / `"\0json\0{obj_index}"` — all disjoint because a C key can
2673/// neither contain nor start with a NUL.
2674fn cache_cstr(m: &TgmMetadata, key: String, build: impl FnOnce() -> String) -> *const c_char {
2675    use std::collections::btree_map::Entry;
2676    match m.cache.borrow_mut().entry(key) {
2677        Entry::Occupied(e) => e.into_mut().as_ptr(),
2678        Entry::Vacant(slot) => match CString::new(build()) {
2679            Ok(cs) => slot.insert(cs).as_ptr(),
2680            Err(_) => ptr::null(),
2681        },
2682    }
2683}
2684
2685// ---------------------------------------------------------------------------
2686// simple_packing direct access
2687// ---------------------------------------------------------------------------
2688
2689/// Compute simple_packing parameters for a set of f64 values.
2690///
2691/// Returns TgmError::Ok on success, filling the out-params.
2692/// Returns Encoding error if data contains NaN.
2693#[unsafe(no_mangle)]
2694pub extern "C" fn tgm_simple_packing_compute_params(
2695    values: *const f64,
2696    num_values: usize,
2697    bits_per_value: u32,
2698    decimal_scale_factor: i32,
2699    out_reference_value: *mut f64,
2700    out_binary_scale_factor: *mut i32,
2701) -> TgmError {
2702    if values.is_null() || out_reference_value.is_null() || out_binary_scale_factor.is_null() {
2703        set_last_error("null argument");
2704        return TgmError::InvalidArg;
2705    }
2706
2707    let vals = unsafe { slice::from_raw_parts(values, num_values) };
2708
2709    match tensogram_encodings::simple_packing::compute_params(
2710        vals,
2711        bits_per_value,
2712        decimal_scale_factor,
2713    ) {
2714        Ok(params) => {
2715            unsafe {
2716                *out_reference_value = params.reference_value;
2717                *out_binary_scale_factor = params.binary_scale_factor;
2718            }
2719            TgmError::Ok
2720        }
2721        Err(e) => {
2722            set_last_error(&e.to_string());
2723            TgmError::Encoding
2724        }
2725    }
2726}
2727
2728// ---------------------------------------------------------------------------
2729// Iterator API
2730// ---------------------------------------------------------------------------
2731
2732/// Opaque handle for iterating over messages in a byte buffer.
2733///
2734/// The caller's buffer must remain valid for the lifetime of this iterator.
2735pub struct TgmBufferIter {
2736    offsets: Vec<(usize, usize)>,
2737    buf_ptr: *const u8,
2738    pos: usize,
2739}
2740
2741/// Create a buffer message iterator.
2742///
2743/// Scans `buf` once and stores message boundaries. The buffer must remain
2744/// valid and unmodified until `tgm_buffer_iter_free` is called.
2745#[unsafe(no_mangle)]
2746pub extern "C" fn tgm_buffer_iter_create(
2747    buf: *const u8,
2748    buf_len: usize,
2749    out: *mut *mut TgmBufferIter,
2750) -> TgmError {
2751    if buf.is_null() || out.is_null() {
2752        set_last_error("null argument");
2753        return TgmError::InvalidArg;
2754    }
2755    let data = unsafe { slice::from_raw_parts(buf, buf_len) };
2756    let offsets = scan(data);
2757    let iter = Box::new(TgmBufferIter {
2758        offsets,
2759        buf_ptr: buf,
2760        pos: 0,
2761    });
2762    unsafe {
2763        *out = Box::into_raw(iter);
2764    }
2765    TgmError::Ok
2766}
2767
2768/// Return the total number of messages in the buffer iterator.
2769#[unsafe(no_mangle)]
2770pub extern "C" fn tgm_buffer_iter_count(iter: *const TgmBufferIter) -> usize {
2771    if iter.is_null() {
2772        return 0;
2773    }
2774    unsafe { (*iter).offsets.len() }
2775}
2776
2777/// Advance the buffer iterator. On success, sets `out_buf` and `out_len` to
2778/// the next message slice (borrowed from the original buffer).
2779///
2780/// Returns `TgmError::Ok` if a message is available, `TgmError::EndOfIter`
2781/// when iteration is exhausted.
2782#[unsafe(no_mangle)]
2783pub extern "C" fn tgm_buffer_iter_next(
2784    iter: *mut TgmBufferIter,
2785    out_buf: *mut *const u8,
2786    out_len: *mut usize,
2787) -> TgmError {
2788    if iter.is_null() || out_buf.is_null() || out_len.is_null() {
2789        set_last_error("null argument");
2790        return TgmError::InvalidArg;
2791    }
2792    let it = unsafe { &mut *iter };
2793    if it.pos >= it.offsets.len() {
2794        return TgmError::EndOfIter;
2795    }
2796    let (offset, length) = it.offsets[it.pos];
2797    it.pos += 1;
2798    unsafe {
2799        *out_buf = it.buf_ptr.add(offset);
2800        *out_len = length;
2801    }
2802    TgmError::Ok
2803}
2804
2805/// Free a buffer iterator handle.
2806#[unsafe(no_mangle)]
2807pub extern "C" fn tgm_buffer_iter_free(iter: *mut TgmBufferIter) {
2808    if !iter.is_null() {
2809        unsafe {
2810            drop(Box::from_raw(iter));
2811        }
2812    }
2813}
2814
2815/// Opaque handle for iterating over messages in a file.
2816pub struct TgmFileIter {
2817    inner: tensogram::FileMessageIter,
2818}
2819
2820/// Create a file message iterator from an open TgmFile.
2821///
2822/// Scans the file to locate message boundaries. The file handle remains
2823/// usable after this call.
2824#[unsafe(no_mangle)]
2825pub extern "C" fn tgm_file_iter_create(file: *mut TgmFile, out: *mut *mut TgmFileIter) -> TgmError {
2826    if file.is_null() || out.is_null() {
2827        set_last_error("null argument");
2828        return TgmError::InvalidArg;
2829    }
2830    let f = unsafe { &(*file).file };
2831    match f.iter() {
2832        Ok(inner) => {
2833            let iter = Box::new(TgmFileIter { inner });
2834            unsafe {
2835                *out = Box::into_raw(iter);
2836            }
2837            TgmError::Ok
2838        }
2839        Err(e) => {
2840            set_last_error(&e.to_string());
2841            to_error_code(&e)
2842        }
2843    }
2844}
2845
2846/// Advance the file iterator. On success, fills `out` with a `TgmBytes`
2847/// buffer containing the raw message bytes (caller owns, free with
2848/// `tgm_bytes_free`).
2849///
2850/// Returns `TgmError::Ok` when a message is available, `TgmError::EndOfIter`
2851/// when iteration is exhausted.
2852#[unsafe(no_mangle)]
2853pub extern "C" fn tgm_file_iter_next(iter: *mut TgmFileIter, out: *mut TgmBytes) -> TgmError {
2854    if iter.is_null() || out.is_null() {
2855        set_last_error("null argument");
2856        return TgmError::InvalidArg;
2857    }
2858    let it = unsafe { &mut (*iter).inner };
2859    match it.next() {
2860        None => TgmError::EndOfIter,
2861        Some(Err(e)) => {
2862            set_last_error(&e.to_string());
2863            to_error_code(&e)
2864        }
2865        Some(Ok(bytes)) => {
2866            // Rebuild via boxed slice to guarantee capacity == len for tgm_bytes_free.
2867            let mut bytes = bytes.into_boxed_slice().into_vec();
2868            let result = TgmBytes {
2869                data: bytes.as_mut_ptr(),
2870                len: bytes.len(),
2871            };
2872            std::mem::forget(bytes);
2873            unsafe {
2874                *out = result;
2875            }
2876            TgmError::Ok
2877        }
2878    }
2879}
2880
2881/// Free a file iterator handle.
2882#[unsafe(no_mangle)]
2883pub extern "C" fn tgm_file_iter_free(iter: *mut TgmFileIter) {
2884    if !iter.is_null() {
2885        unsafe {
2886            drop(Box::from_raw(iter));
2887        }
2888    }
2889}
2890
2891/// Opaque handle for iterating over objects within a single message.
2892pub struct TgmObjectIter {
2893    inner: tensogram::ObjectIter,
2894    /// Global metadata parsed from the message header, cloned into each
2895    /// yielded `TgmMessage` to preserve the original version and extra fields.
2896    global_metadata: GlobalMetadata,
2897}
2898
2899/// Create an object iterator from raw message bytes.
2900///
2901/// Parses metadata once, then decodes each object on demand when
2902/// `tgm_object_iter_next` is called. The global metadata from the
2903/// original message is preserved in each yielded `TgmMessage`.
2904#[unsafe(no_mangle)]
2905pub extern "C" fn tgm_object_iter_create(
2906    buf: *const u8,
2907    buf_len: usize,
2908    native_byte_order: i32,
2909    verify_hash: i32,
2910    out: *mut *mut TgmObjectIter,
2911) -> TgmError {
2912    if buf.is_null() || out.is_null() {
2913        set_last_error("null argument");
2914        return TgmError::InvalidArg;
2915    }
2916    let data = unsafe { slice::from_raw_parts(buf, buf_len) };
2917    let options = DecodeOptions {
2918        native_byte_order: native_byte_order != 0,
2919        verify_hash: verify_hash != 0,
2920        ..Default::default()
2921    };
2922
2923    // Parse global metadata from the message header so we can attach it to
2924    // each yielded TgmMessage instead of fabricating a default.
2925    let global_metadata = decode_metadata(data).unwrap_or_default();
2926
2927    match tensogram::objects(data, options) {
2928        Ok(inner) => {
2929            let iter = Box::new(TgmObjectIter {
2930                inner,
2931                global_metadata,
2932            });
2933            unsafe {
2934                *out = Box::into_raw(iter);
2935            }
2936            TgmError::Ok
2937        }
2938        Err(e) => {
2939            set_last_error(&e.to_string());
2940            to_error_code(&e)
2941        }
2942    }
2943}
2944
2945/// Advance the object iterator. On success, fills `out` with a `TgmMessage`
2946/// handle containing exactly one decoded object (the next in sequence).
2947///
2948/// Returns `TgmError::Ok` when an object is available, `TgmError::EndOfIter`
2949/// when iteration is exhausted. Free each yielded `TgmMessage` with
2950/// `tgm_message_free`.
2951#[unsafe(no_mangle)]
2952pub extern "C" fn tgm_object_iter_next(
2953    iter: *mut TgmObjectIter,
2954    out: *mut *mut TgmMessage,
2955) -> TgmError {
2956    if iter.is_null() || out.is_null() {
2957        set_last_error("null argument");
2958        return TgmError::InvalidArg;
2959    }
2960    let it = unsafe { &mut *iter };
2961    match it.inner.next() {
2962        None => TgmError::EndOfIter,
2963        Some(Err(e)) => {
2964            set_last_error(&e.to_string());
2965            to_error_code(&e)
2966        }
2967        Some(Ok((descriptor, data))) => {
2968            let global_metadata = it.global_metadata.clone();
2969            let objects = vec![(descriptor, data)];
2970            // Iterator path: the object iterator's `data` is the
2971            // already-decoded payload; the original frame's inline
2972            // hash slot isn't accessible from this layer without
2973            // re-reading from the source and re-scanning.  Callers
2974            // that need per-object hashes should either use
2975            // `tgm_file_decode_message` (which surfaces the hash
2976            // via the file-re-read path), or the buffer-based
2977            // `tgm_decode` if the raw bytes are already in memory.
2978            let caches = build_message_caches(&objects, &[]);
2979            let msg = Box::new(TgmMessage {
2980                global_metadata,
2981                objects,
2982                dtype_strings: caches.dtype_strings,
2983                type_strings: caches.type_strings,
2984                byte_order_strings: caches.byte_order_strings,
2985                filter_strings: caches.filter_strings,
2986                compression_strings: caches.compression_strings,
2987                encoding_strings: caches.encoding_strings,
2988                hash_type_strings: caches.hash_type_strings,
2989                hash_value_strings: caches.hash_value_strings,
2990            });
2991            unsafe {
2992                *out = Box::into_raw(msg);
2993            }
2994            TgmError::Ok
2995        }
2996    }
2997}
2998
2999/// Free an object iterator handle.
3000#[unsafe(no_mangle)]
3001pub extern "C" fn tgm_object_iter_free(iter: *mut TgmObjectIter) {
3002    if !iter.is_null() {
3003        unsafe {
3004            drop(Box::from_raw(iter));
3005        }
3006    }
3007}
3008
3009// ---------------------------------------------------------------------------
3010// Error code to string
3011// ---------------------------------------------------------------------------
3012
3013/// Convert an error code to a human-readable string.
3014/// Returns a static string (always valid, never NULL).
3015///
3016/// Accepts a raw integer and matches by value so that invalid discriminants
3017/// from C callers do not trigger undefined behaviour in Rust.
3018#[unsafe(no_mangle)]
3019pub extern "C" fn tgm_error_string(err: TgmError) -> *const c_char {
3020    // Convert to integer for safe matching — C callers may pass invalid values.
3021    let code = err as i32;
3022    let s: &[u8] = match code {
3023        0 => b"ok\0",
3024        1 => b"framing error\0",
3025        2 => b"metadata error\0",
3026        3 => b"encoding error\0",
3027        4 => b"compression error\0",
3028        5 => b"object error\0",
3029        6 => b"I/O error\0",
3030        7 => b"hash mismatch\0",
3031        8 => b"invalid argument\0",
3032        9 => b"end of iteration\0",
3033        10 => b"remote error\0",
3034        11 => b"missing hash\0",
3035        12 => b"async task timed out\0",
3036        13 => b"async task cancelled\0",
3037        _ => b"unknown error\0",
3038    };
3039    s.as_ptr() as *const c_char
3040}
3041
3042// ---------------------------------------------------------------------------
3043// Hash utilities
3044// ---------------------------------------------------------------------------
3045
3046// ---------------------------------------------------------------------------
3047// Unit tests for metadata lookup helpers and JSON parsing
3048// ---------------------------------------------------------------------------
3049
3050#[cfg(test)]
3051mod tests {
3052    use super::*;
3053    use std::collections::BTreeMap;
3054
3055    fn make_meta(
3056        base: Vec<BTreeMap<String, ciborium::Value>>,
3057        extra: BTreeMap<String, ciborium::Value>,
3058    ) -> GlobalMetadata {
3059        GlobalMetadata {
3060            base,
3061            extra,
3062            ..Default::default()
3063        }
3064    }
3065
3066    // ── lookup_cbor_value ─────────────────────────────────────────────
3067
3068    #[test]
3069    fn lookup_cbor_empty_key() {
3070        let meta = make_meta(vec![], BTreeMap::new());
3071        assert!(lookup_cbor_value(&meta, "").is_none());
3072    }
3073
3074    #[test]
3075    fn lookup_cbor_dot_only() {
3076        let meta = make_meta(vec![], BTreeMap::new());
3077        assert!(lookup_cbor_value(&meta, ".").is_none());
3078    }
3079
3080    #[test]
3081    fn lookup_cbor_version_returns_none() {
3082        // version is handled by tgm_metadata_version, not lookup_cbor_value
3083        let meta = make_meta(vec![], BTreeMap::new());
3084        assert!(lookup_cbor_value(&meta, "version").is_none());
3085    }
3086
3087    #[test]
3088    fn lookup_cbor_base_match() {
3089        let mut entry = BTreeMap::new();
3090        entry.insert("centre".into(), ciborium::Value::Text("ecmwf".into()));
3091        let meta = make_meta(vec![entry], BTreeMap::new());
3092        let val = lookup_cbor_value(&meta, "centre");
3093        assert!(matches!(val, Some(ciborium::Value::Text(s)) if s == "ecmwf"));
3094    }
3095
3096    #[test]
3097    fn lookup_cbor_extra_fallback() {
3098        // Key not in base → found in extra
3099        let mut extra = BTreeMap::new();
3100        extra.insert("source".into(), ciborium::Value::Text("test".into()));
3101        let meta = make_meta(vec![], extra);
3102        let val = lookup_cbor_value(&meta, "source");
3103        assert!(matches!(val, Some(ciborium::Value::Text(s)) if s == "test"));
3104    }
3105
3106    #[test]
3107    fn lookup_cbor_no_match() {
3108        let meta = make_meta(vec![], BTreeMap::new());
3109        assert!(lookup_cbor_value(&meta, "nonexistent").is_none());
3110    }
3111
3112    #[test]
3113    fn lookup_cbor_reserved_skipped() {
3114        let mut entry = BTreeMap::new();
3115        entry.insert(
3116            "_reserved_".into(),
3117            ciborium::Value::Map(vec![(
3118                ciborium::Value::Text("tensor".into()),
3119                ciborium::Value::Text("internal".into()),
3120            )]),
3121        );
3122        entry.insert("param".into(), ciborium::Value::Text("2t".into()));
3123        let meta = make_meta(vec![entry], BTreeMap::new());
3124        // _reserved_ path should be skipped
3125        assert!(lookup_cbor_value(&meta, "_reserved_.tensor").is_none());
3126        // Regular key should still be found
3127        assert!(lookup_cbor_value(&meta, "param").is_some());
3128    }
3129
3130    #[test]
3131    fn lookup_cbor_extra_prefix() {
3132        let mut extra = BTreeMap::new();
3133        extra.insert("custom".into(), ciborium::Value::Text("val".into()));
3134        let meta = make_meta(vec![], extra);
3135        // _extra_.custom should resolve directly in extra
3136        let val = lookup_cbor_value(&meta, "_extra_.custom");
3137        assert!(matches!(val, Some(ciborium::Value::Text(s)) if s == "val"));
3138    }
3139
3140    #[test]
3141    fn lookup_cbor_extra_alias_prefix() {
3142        let mut extra = BTreeMap::new();
3143        extra.insert("custom".into(), ciborium::Value::Text("val".into()));
3144        let meta = make_meta(vec![], extra);
3145        // extra.custom should also resolve in extra
3146        let val = lookup_cbor_value(&meta, "extra.custom");
3147        assert!(matches!(val, Some(ciborium::Value::Text(s)) if s == "val"));
3148    }
3149
3150    #[test]
3151    fn lookup_cbor_extra_prefix_alone_returns_none() {
3152        let meta = make_meta(vec![], BTreeMap::new());
3153        // Bare "_extra_" without subkey returns None
3154        assert!(lookup_cbor_value(&meta, "_extra_").is_none());
3155        assert!(lookup_cbor_value(&meta, "extra").is_none());
3156    }
3157
3158    #[test]
3159    fn lookup_cbor_base_wins_over_extra() {
3160        let mut entry = BTreeMap::new();
3161        entry.insert("shared".into(), ciborium::Value::Text("from_base".into()));
3162        let mut extra = BTreeMap::new();
3163        extra.insert("shared".into(), ciborium::Value::Text("from_extra".into()));
3164        let meta = make_meta(vec![entry], extra);
3165        let val = lookup_cbor_value(&meta, "shared");
3166        assert!(matches!(val, Some(ciborium::Value::Text(s)) if s == "from_base"));
3167    }
3168
3169    #[test]
3170    fn lookup_cbor_deeply_nested() {
3171        let e_val = ciborium::Value::Map(vec![(
3172            ciborium::Value::Text("e".into()),
3173            ciborium::Value::Text("deep".into()),
3174        )]);
3175        let d_val = ciborium::Value::Map(vec![(ciborium::Value::Text("d".into()), e_val)]);
3176        let c_val = ciborium::Value::Map(vec![(ciborium::Value::Text("c".into()), d_val)]);
3177        let b_val = ciborium::Value::Map(vec![(ciborium::Value::Text("b".into()), c_val)]);
3178        let mut entry = BTreeMap::new();
3179        entry.insert("a".into(), b_val);
3180        let meta = make_meta(vec![entry], BTreeMap::new());
3181        let val = lookup_cbor_value(&meta, "a.b.c.d.e");
3182        assert!(matches!(val, Some(ciborium::Value::Text(s)) if s == "deep"));
3183    }
3184
3185    #[test]
3186    fn lookup_cbor_multi_base_first_match() {
3187        let mut entry0 = BTreeMap::new();
3188        entry0.insert("param".into(), ciborium::Value::Text("2t".into()));
3189        let mut entry1 = BTreeMap::new();
3190        entry1.insert("param".into(), ciborium::Value::Text("msl".into()));
3191        let meta = make_meta(vec![entry0, entry1], BTreeMap::new());
3192        let val = lookup_cbor_value(&meta, "param");
3193        assert!(matches!(val, Some(ciborium::Value::Text(s)) if s == "2t"));
3194    }
3195
3196    // ── resolve_cbor_path ─────────────────────────────────────────────
3197
3198    #[test]
3199    fn resolve_cbor_path_empty_remaining() {
3200        let value = ciborium::Value::Text("hello".into());
3201        assert_eq!(resolve_cbor_path(&value, &[]), Some(&value));
3202    }
3203
3204    #[test]
3205    fn resolve_cbor_path_non_map_with_remaining() {
3206        let value = ciborium::Value::Text("hello".into());
3207        assert!(resolve_cbor_path(&value, &["key"]).is_none());
3208    }
3209
3210    #[test]
3211    fn resolve_cbor_path_map_missing_key() {
3212        let value = ciborium::Value::Map(vec![(
3213            ciborium::Value::Text("a".into()),
3214            ciborium::Value::Text("b".into()),
3215        )]);
3216        assert!(resolve_cbor_path(&value, &["missing"]).is_none());
3217    }
3218
3219    // ── lookup_string_key ──
3220
3221    #[test]
3222    fn lookup_string_key_version() {
3223        let meta = make_meta(vec![], BTreeMap::new());
3224        assert_eq!(lookup_string_key(&meta, "version"), Some("3".into()));
3225    }
3226
3227    #[test]
3228    fn lookup_string_key_empty() {
3229        let meta = make_meta(vec![], BTreeMap::new());
3230        assert!(lookup_string_key(&meta, "").is_none());
3231    }
3232
3233    #[test]
3234    fn lookup_string_key_integer_value() {
3235        let mut entry = BTreeMap::new();
3236        entry.insert("count".into(), ciborium::Value::Integer(42.into()));
3237        let meta = make_meta(vec![entry], BTreeMap::new());
3238        assert_eq!(lookup_string_key(&meta, "count"), Some("42".into()));
3239    }
3240
3241    #[test]
3242    fn lookup_string_key_float_value() {
3243        let mut extra = BTreeMap::new();
3244        extra.insert("temperature".into(), ciborium::Value::Float(98.6));
3245        let meta = make_meta(vec![], extra);
3246        assert_eq!(lookup_string_key(&meta, "temperature"), Some("98.6".into()));
3247    }
3248
3249    #[test]
3250    fn lookup_string_key_bool_value() {
3251        let mut extra = BTreeMap::new();
3252        extra.insert("flag".into(), ciborium::Value::Bool(true));
3253        let meta = make_meta(vec![], extra);
3254        assert_eq!(lookup_string_key(&meta, "flag"), Some("true".into()));
3255    }
3256
3257    #[test]
3258    fn lookup_string_key_null_returns_none() {
3259        let mut extra = BTreeMap::new();
3260        extra.insert("nothing".into(), ciborium::Value::Null);
3261        let meta = make_meta(vec![], extra);
3262        // Null is not a string/int/float/bool, so returns None
3263        assert!(lookup_string_key(&meta, "nothing").is_none());
3264    }
3265
3266    // ── lookup_int_key ──
3267
3268    #[test]
3269    fn lookup_int_key_version() {
3270        let meta = make_meta(vec![], BTreeMap::new());
3271        assert_eq!(lookup_int_key(&meta, "version"), Some(3));
3272    }
3273
3274    #[test]
3275    fn lookup_int_key_non_integer() {
3276        let mut extra = BTreeMap::new();
3277        extra.insert("str".into(), ciborium::Value::Text("not_int".into()));
3278        let meta = make_meta(vec![], extra);
3279        assert!(lookup_int_key(&meta, "str").is_none());
3280    }
3281
3282    // ── lookup_float_key ──
3283
3284    #[test]
3285    fn lookup_float_key_float() {
3286        let mut extra = BTreeMap::new();
3287        extra.insert("val".into(), ciborium::Value::Float(98.6));
3288        let meta = make_meta(vec![], extra);
3289        assert_eq!(lookup_float_key(&meta, "val"), Some(98.6));
3290    }
3291
3292    #[test]
3293    fn lookup_float_key_integer_coercion() {
3294        let mut extra = BTreeMap::new();
3295        extra.insert("count".into(), ciborium::Value::Integer(42.into()));
3296        let meta = make_meta(vec![], extra);
3297        assert_eq!(lookup_float_key(&meta, "count"), Some(42.0));
3298    }
3299
3300    #[test]
3301    fn lookup_float_key_non_numeric() {
3302        let mut extra = BTreeMap::new();
3303        extra.insert("str".into(), ciborium::Value::Text("hello".into()));
3304        let meta = make_meta(vec![], extra);
3305        assert!(lookup_float_key(&meta, "str").is_none());
3306    }
3307
3308    // ── per-object accessors (lookup_cbor_in_object + _at getters) ─────
3309
3310    /// Two-object metadata: object 0 = 2t/sfc, object 1 = msl at 500 hPa with
3311    /// a nested geometry and a `_reserved_.tensor` descriptor.
3312    fn two_object_meta() -> GlobalMetadata {
3313        let mut e0 = BTreeMap::new();
3314        e0.insert("shortName".into(), ciborium::Value::Text("2t".into()));
3315        e0.insert("level".into(), ciborium::Value::Integer(0.into()));
3316        e0.insert(
3317            "_reserved_".into(),
3318            ciborium::Value::Map(vec![(
3319                ciborium::Value::Text("tensor".into()),
3320                ciborium::Value::Map(vec![(
3321                    ciborium::Value::Text("dtype".into()),
3322                    ciborium::Value::Text("float64".into()),
3323                )]),
3324            )]),
3325        );
3326
3327        let mut e1 = BTreeMap::new();
3328        e1.insert("shortName".into(), ciborium::Value::Text("msl".into()));
3329        e1.insert("level".into(), ciborium::Value::Integer(500.into()));
3330        e1.insert("scale".into(), ciborium::Value::Float(0.01));
3331        e1.insert(
3332            "geometry".into(),
3333            ciborium::Value::Map(vec![(
3334                ciborium::Value::Text("gridType".into()),
3335                ciborium::Value::Text("regular_ll".into()),
3336            )]),
3337        );
3338        make_meta(vec![e0, e1], BTreeMap::new())
3339    }
3340
3341    #[test]
3342    fn lookup_in_object_scopes_to_index() {
3343        let m = two_object_meta();
3344        assert!(matches!(
3345            lookup_cbor_in_object(&m.base[0], "shortName"),
3346            Some(ciborium::Value::Text(s)) if s == "2t"
3347        ));
3348        assert!(matches!(
3349            lookup_cbor_in_object(&m.base[1], "shortName"),
3350            Some(ciborium::Value::Text(s)) if s == "msl"
3351        ));
3352    }
3353
3354    #[test]
3355    fn lookup_in_object_nested_dot_path() {
3356        let m = two_object_meta();
3357        assert!(matches!(
3358            lookup_cbor_in_object(&m.base[1], "geometry.gridType"),
3359            Some(ciborium::Value::Text(s)) if s == "regular_ll"
3360        ));
3361        // object 0 has no geometry
3362        assert!(lookup_cbor_in_object(&m.base[0], "geometry.gridType").is_none());
3363    }
3364
3365    #[test]
3366    fn lookup_in_object_skips_reserved() {
3367        let m = two_object_meta();
3368        assert!(lookup_cbor_in_object(&m.base[0], "_reserved_.tensor.dtype").is_none());
3369    }
3370
3371    #[test]
3372    fn lookup_in_object_empty_key() {
3373        let m = two_object_meta();
3374        assert!(lookup_cbor_in_object(&m.base[0], "").is_none());
3375    }
3376
3377    /// Build a live handle and exercise the extern `_at` getters end-to-end.
3378    fn make_handle(gm: GlobalMetadata) -> TgmMetadata {
3379        TgmMetadata {
3380            global_metadata: gm,
3381            cache: std::cell::RefCell::new(BTreeMap::new()),
3382        }
3383    }
3384
3385    #[test]
3386    fn get_string_at_reads_each_object() {
3387        let h = make_handle(two_object_meta());
3388        let hp = &h as *const TgmMetadata;
3389        let k = CString::new("shortName").unwrap();
3390        let s0 = tgm_metadata_get_string_at(hp, 0, k.as_ptr());
3391        let s1 = tgm_metadata_get_string_at(hp, 1, k.as_ptr());
3392        assert!(!s0.is_null() && !s1.is_null());
3393        let v0 = unsafe { CStr::from_ptr(s0) }.to_str().unwrap();
3394        let v1 = unsafe { CStr::from_ptr(s1) }.to_str().unwrap();
3395        assert_eq!((v0, v1), ("2t", "msl"));
3396    }
3397
3398    #[test]
3399    fn get_int_and_float_at() {
3400        let h = make_handle(two_object_meta());
3401        let hp = &h as *const TgmMetadata;
3402        let level = CString::new("level").unwrap();
3403        assert_eq!(tgm_metadata_get_int_at(hp, 0, level.as_ptr(), -1), 0);
3404        assert_eq!(tgm_metadata_get_int_at(hp, 1, level.as_ptr(), -1), 500);
3405        let scale = CString::new("scale").unwrap();
3406        assert_eq!(tgm_metadata_get_float_at(hp, 1, scale.as_ptr(), 0.0), 0.01);
3407        // absent key → default; wrong type → default
3408        let missing = CString::new("nope").unwrap();
3409        assert_eq!(tgm_metadata_get_int_at(hp, 0, missing.as_ptr(), 42), 42);
3410        assert_eq!(tgm_metadata_get_int_at(hp, 1, scale.as_ptr(), 42), 42);
3411    }
3412
3413    #[test]
3414    fn get_at_out_of_range_and_null() {
3415        let h = make_handle(two_object_meta());
3416        let hp = &h as *const TgmMetadata;
3417        let k = CString::new("shortName").unwrap();
3418        assert!(tgm_metadata_get_string_at(hp, 9, k.as_ptr()).is_null());
3419        assert_eq!(tgm_metadata_get_int_at(hp, 9, k.as_ptr(), 7), 7);
3420        assert!(tgm_metadata_get_string_at(ptr::null(), 0, k.as_ptr()).is_null());
3421    }
3422
3423    #[test]
3424    fn object_to_json_roundtrips_via_serde() {
3425        let h = make_handle(two_object_meta());
3426        let hp = &h as *const TgmMetadata;
3427        let p = tgm_metadata_object_to_json(hp, 1);
3428        assert!(!p.is_null());
3429        let text = unsafe { CStr::from_ptr(p) }.to_str().unwrap();
3430        let v: serde_json::Value = serde_json::from_str(text).unwrap();
3431        assert_eq!(v["shortName"], "msl");
3432        assert_eq!(v["level"], 500);
3433        assert_eq!(v["geometry"]["gridType"], "regular_ll");
3434        // out of range → null
3435        assert!(tgm_metadata_object_to_json(hp, 9).is_null());
3436    }
3437
3438    #[test]
3439    fn to_json_has_base_array() {
3440        let h = make_handle(two_object_meta());
3441        let p = tgm_metadata_to_json(&h as *const TgmMetadata);
3442        let text = unsafe { CStr::from_ptr(p) }.to_str().unwrap();
3443        let v: serde_json::Value = serde_json::from_str(text).unwrap();
3444        assert_eq!(v["base"].as_array().unwrap().len(), 2);
3445        assert_eq!(v["base"][0]["shortName"], "2t");
3446    }
3447
3448    #[test]
3449    fn cbor_to_json_handles_awkward_kinds() {
3450        // bytes → hex, non-finite float → null, big int → string
3451        assert_eq!(
3452            cbor_to_json(&ciborium::Value::Bytes(vec![0xde, 0xad])),
3453            serde_json::Value::String("dead".into())
3454        );
3455        assert_eq!(
3456            cbor_to_json(&ciborium::Value::Float(f64::NAN)),
3457            serde_json::Value::Null
3458        );
3459        // u64::MAX exceeds i64 but is a valid CBOR integer → JSON number via u64.
3460        assert_eq!(
3461            cbor_to_json(&ciborium::Value::Integer(u64::MAX.into())),
3462            serde_json::Value::from(u64::MAX)
3463        );
3464    }
3465
3466    #[test]
3467    fn get_string_at_container_value_is_null() {
3468        // The key resolves to a map, not a scalar → NULL (not a stringified map).
3469        let h = make_handle(two_object_meta());
3470        let k = CString::new("geometry").unwrap();
3471        assert!(tgm_metadata_get_string_at(&h as *const TgmMetadata, 1, k.as_ptr()).is_null());
3472    }
3473
3474    #[test]
3475    fn get_at_exact_boundary_index() {
3476        // obj_index == num_objects (the classic off-by-one) must miss, not panic.
3477        let h = make_handle(two_object_meta());
3478        let hp = &h as *const TgmMetadata;
3479        let k = CString::new("shortName").unwrap();
3480        assert!(tgm_metadata_get_string_at(hp, 2, k.as_ptr()).is_null());
3481        assert_eq!(tgm_metadata_get_int_at(hp, 2, k.as_ptr(), -1), -1);
3482        assert!(tgm_metadata_object_to_json(hp, 2).is_null());
3483    }
3484
3485    #[test]
3486    fn json_exporters_null_handle() {
3487        let k = CString::new("x").unwrap();
3488        assert!(tgm_metadata_object_to_json(ptr::null(), 0).is_null());
3489        assert!(tgm_metadata_to_json(ptr::null()).is_null());
3490        assert!(tgm_metadata_get_int_at(ptr::null(), 0, k.as_ptr(), 5) == 5);
3491        assert!(tgm_metadata_get_float_at(ptr::null(), 0, k.as_ptr(), 1.5) == 1.5);
3492    }
3493
3494    #[test]
3495    fn to_json_on_empty_metadata_is_empty_object() {
3496        let h = make_handle(make_meta(vec![], BTreeMap::new()));
3497        let p = tgm_metadata_to_json(&h as *const TgmMetadata);
3498        let text = unsafe { CStr::from_ptr(p) }.to_str().unwrap();
3499        assert_eq!(text, "{}");
3500    }
3501
3502    #[test]
3503    fn cache_cstr_interior_nul_returns_null() {
3504        // A value with an interior NUL is not representable as a C string, so
3505        // cache_cstr returns NULL (not a truncated empty string, not a panic).
3506        let h = make_handle(make_meta(vec![], BTreeMap::new()));
3507        assert!(cache_cstr(&h, "k".to_string(), || "a\0b".to_string()).is_null());
3508    }
3509
3510    #[test]
3511    fn get_string_at_no_cross_object_bleed() {
3512        // Only object 1 carries `geometry`; object 0 must not first-match into it.
3513        let h = make_handle(two_object_meta());
3514        let hp = &h as *const TgmMetadata;
3515        let k = CString::new("geometry.gridType").unwrap();
3516        assert!(tgm_metadata_get_string_at(hp, 0, k.as_ptr()).is_null());
3517        let s1 = tgm_metadata_get_string_at(hp, 1, k.as_ptr());
3518        assert_eq!(
3519            unsafe { CStr::from_ptr(s1) }.to_str().unwrap(),
3520            "regular_ll"
3521        );
3522    }
3523
3524    #[test]
3525    fn get_string_at_nul_value_returns_null() {
3526        // A stored value with an interior NUL is not representable as a C string,
3527        // so it surfaces as NULL end-to-end (not "", not a panic).
3528        let mut e = BTreeMap::new();
3529        e.insert("weird".into(), ciborium::Value::Text("a\0b".into()));
3530        let h = make_handle(make_meta(vec![e], BTreeMap::new()));
3531        let k = CString::new("weird").unwrap();
3532        assert!(tgm_metadata_get_string_at(&h as *const TgmMetadata, 0, k.as_ptr()).is_null());
3533    }
3534
3535    #[test]
3536    fn get_float_at_wrong_type_returns_default() {
3537        // base[0].shortName is text → the float accessor yields the default.
3538        let h = make_handle(two_object_meta());
3539        let k = CString::new("shortName").unwrap();
3540        assert_eq!(
3541            tgm_metadata_get_float_at(&h as *const TgmMetadata, 0, k.as_ptr(), 9.5),
3542            9.5
3543        );
3544    }
3545
3546    #[test]
3547    fn object_to_json_serialises_arrays() {
3548        let mut e = BTreeMap::new();
3549        e.insert(
3550            "pl".into(),
3551            ciborium::Value::Array(vec![
3552                ciborium::Value::Integer(1.into()),
3553                ciborium::Value::Integer(2.into()),
3554            ]),
3555        );
3556        let h = make_handle(make_meta(vec![e], BTreeMap::new()));
3557        let p = tgm_metadata_object_to_json(&h as *const TgmMetadata, 0);
3558        let text = unsafe { CStr::from_ptr(p) }.to_str().unwrap();
3559        let v: serde_json::Value = serde_json::from_str(text).unwrap();
3560        assert_eq!(v["pl"], serde_json::json!([1, 2]));
3561    }
3562
3563    #[test]
3564    fn object_to_json_caches_pointer_across_calls() {
3565        // A repeat call returns the same cached CString — proving `build` runs
3566        // once (lazily) and is not re-serialised on a hit.
3567        let h = make_handle(two_object_meta());
3568        let hp = &h as *const TgmMetadata;
3569        let p1 = tgm_metadata_object_to_json(hp, 0);
3570        let p2 = tgm_metadata_object_to_json(hp, 0);
3571        assert!(!p1.is_null());
3572        assert_eq!(p1, p2);
3573    }
3574
3575    // ── parse_encode_json ──
3576
3577    #[test]
3578    fn parse_encode_json_with_base() {
3579        let json = r#"{"version":3,"base":[{"mars":{"param":"2t"}}],"descriptors":[]}"#;
3580        let (gm, descs) = parse_encode_json(json).unwrap();
3581        assert_eq!(gm.base.len(), 1);
3582        assert!(gm.base[0].contains_key("mars"));
3583        assert!(descs.is_empty());
3584    }
3585
3586    #[test]
3587    fn parse_encode_json_legacy_version_routed_to_extra() {
3588        // A caller-supplied legacy top-level `"version"` lands in
3589        // `_extra_["version"]` on decode — matching the Python /
3590        // TypeScript / Rust-core contract.  See Copilot review on
3591        // PR #80.
3592        let json = r#"{"version":3,"descriptors":[]}"#;
3593        let (gm, _) = parse_encode_json(json).unwrap();
3594        assert_eq!(
3595            gm.extra.get("version"),
3596            Some(&ciborium::Value::Integer(3u64.into())),
3597            "legacy JSON `version` must round-trip via `_extra_`"
3598        );
3599    }
3600
3601    #[test]
3602    fn parse_encode_json_free_form_top_level_routed_to_extra() {
3603        // Parity with the Rust core + Python: unknown JSON top-level
3604        // keys flow into `_extra_`.
3605        let json = r#"{"source":"test","count":42,"descriptors":[]}"#;
3606        let (gm, _) = parse_encode_json(json).unwrap();
3607        assert_eq!(
3608            gm.extra.get("source"),
3609            Some(&ciborium::Value::Text("test".to_string()))
3610        );
3611        assert_eq!(
3612            gm.extra.get("count"),
3613            Some(&ciborium::Value::Integer(42u64.into()))
3614        );
3615    }
3616
3617    #[test]
3618    fn parse_encode_json_explicit_extra_unpacked() {
3619        // An explicit `"_extra_"` section at the top level of the FFI
3620        // JSON must be unpacked into `GlobalMetadata.extra` — matching
3621        // the Python / TypeScript / Rust-core contract.  If `_extra_`
3622        // were treated as just another free-form key, a caller doing
3623        // `{"_extra_": {"foo": "bar"}}` would end up with a nested
3624        // `_extra_._extra_.foo` on the wire — clearly wrong.
3625        let json = r#"{"_extra_":{"foo":"bar","count":7},"descriptors":[]}"#;
3626        let (gm, _) = parse_encode_json(json).unwrap();
3627        assert_eq!(
3628            gm.extra.get("foo"),
3629            Some(&ciborium::Value::Text("bar".to_string())),
3630            "explicit `_extra_.foo` must surface at the top level of `extra`"
3631        );
3632        assert_eq!(
3633            gm.extra.get("count"),
3634            Some(&ciborium::Value::Integer(7u64.into())),
3635            "explicit `_extra_.count` must surface at the top level of `extra`"
3636        );
3637        assert!(
3638            !gm.extra.contains_key("_extra_"),
3639            "there must be no nested `_extra_` key inside `extra`"
3640        );
3641    }
3642
3643    #[test]
3644    fn parse_encode_json_explicit_extra_beats_free_form() {
3645        // `explicit beats implicit`: when both an explicit `_extra_.X`
3646        // and a free-form top-level `X` are supplied, the explicit
3647        // entry wins.  Matches the Rust core + Python / TS behaviour.
3648        let json = r#"{"version":99,"_extra_":{"version":1},"descriptors":[]}"#;
3649        let (gm, _) = parse_encode_json(json).unwrap();
3650        assert_eq!(
3651            gm.extra.get("version"),
3652            Some(&ciborium::Value::Integer(1u64.into())),
3653            "explicit _extra_.version must win over top-level version"
3654        );
3655    }
3656
3657    #[test]
3658    fn parse_encode_json_without_base() {
3659        let json = r#"{"version":3,"descriptors":[]}"#;
3660        let (gm, _) = parse_encode_json(json).unwrap();
3661        assert!(gm.base.is_empty());
3662    }
3663
3664    #[test]
3665    fn parse_encode_json_reserved_in_base_rejected() {
3666        let json = r#"{"version":3,"base":[{"_reserved_":{"tensor":{}}}],"descriptors":[]}"#;
3667        let result = parse_encode_json(json);
3668        assert!(result.is_err());
3669        assert!(result.unwrap_err().contains("_reserved_"));
3670    }
3671
3672    #[test]
3673    fn parse_encode_json_extra_keys() {
3674        let json = r#"{"version":3,"descriptors":[],"source":"test","count":42}"#;
3675        let (gm, _) = parse_encode_json(json).unwrap();
3676        assert!(gm.extra.contains_key("source"));
3677        assert!(gm.extra.contains_key("count"));
3678    }
3679
3680    // ── parse_streaming_metadata_json ──
3681
3682    #[test]
3683    fn parse_streaming_json_with_base() {
3684        let json = r#"{"version":3,"base":[{"mars":{"param":"2t"}}]}"#;
3685        let gm = parse_streaming_metadata_json(json).unwrap();
3686        assert_eq!(gm.base.len(), 1);
3687    }
3688
3689    #[test]
3690    fn parse_streaming_json_reserved_rejected() {
3691        let json = r#"{"version":3,"base":[{"_reserved_":{"tensor":{}}}]}"#;
3692        let result = parse_streaming_metadata_json(json);
3693        assert!(result.is_err());
3694        assert!(result.unwrap_err().contains("_reserved_"));
3695    }
3696
3697    #[test]
3698    fn parse_streaming_json_no_base() {
3699        let json = r#"{"version":3,"source":"stream"}"#;
3700        let gm = parse_streaming_metadata_json(json).unwrap();
3701        assert!(gm.base.is_empty());
3702        assert!(gm.extra.contains_key("source"));
3703    }
3704
3705    #[test]
3706    fn parse_streaming_json_explicit_extra_unpacked() {
3707        // Streaming path must honour the same `_extra_` unpacking as
3708        // `parse_encode_json`.
3709        let json = r#"{"_extra_":{"foo":"bar"}}"#;
3710        let gm = parse_streaming_metadata_json(json).unwrap();
3711        assert_eq!(
3712            gm.extra.get("foo"),
3713            Some(&ciborium::Value::Text("bar".to_string()))
3714        );
3715        assert!(!gm.extra.contains_key("_extra_"));
3716    }
3717
3718    #[test]
3719    fn parse_streaming_json_explicit_extra_beats_free_form() {
3720        // `explicit beats implicit` on the streaming path too.
3721        let json = r#"{"version":99,"_extra_":{"version":1}}"#;
3722        let gm = parse_streaming_metadata_json(json).unwrap();
3723        assert_eq!(
3724            gm.extra.get("version"),
3725            Some(&ciborium::Value::Integer(1u64.into()))
3726        );
3727    }
3728
3729    #[test]
3730    fn parse_streaming_json_invalid_json() {
3731        assert!(parse_streaming_metadata_json("not json").is_err());
3732    }
3733
3734    #[test]
3735    fn parse_encode_json_rejects_both_extra_aliases() {
3736        // `_extra_` and `extra` are aliases for the same concept;
3737        // supplying both is ambiguous and rejected by the helper.
3738        let json = r#"{"_extra_":{"a":1},"extra":{"b":2},"descriptors":[]}"#;
3739        let err = parse_encode_json(json).unwrap_err();
3740        assert!(
3741            err.contains("both '_extra_' and 'extra'"),
3742            "unexpected error: {err}"
3743        );
3744    }
3745
3746    #[test]
3747    fn parse_encode_json_rejects_non_object_extra() {
3748        // `_extra_` must be a JSON object.  A scalar is a caller
3749        // error and surfaces a clear message.
3750        let json = r#"{"_extra_":42,"descriptors":[]}"#;
3751        let err = parse_encode_json(json).unwrap_err();
3752        assert!(
3753            err.contains("'_extra_' must be a JSON object"),
3754            "unexpected error: {err}"
3755        );
3756    }
3757
3758    #[test]
3759    fn parse_encode_json_invalid_json() {
3760        assert!(parse_encode_json("not json").is_err());
3761    }
3762
3763    // ── json_to_cbor ──
3764
3765    #[test]
3766    fn json_to_cbor_null() {
3767        assert_eq!(json_to_cbor(serde_json::Value::Null), ciborium::Value::Null);
3768    }
3769
3770    #[test]
3771    fn json_to_cbor_bool() {
3772        assert_eq!(
3773            json_to_cbor(serde_json::Value::Bool(true)),
3774            ciborium::Value::Bool(true)
3775        );
3776    }
3777
3778    #[test]
3779    fn json_to_cbor_integer() {
3780        let val = serde_json::json!(42);
3781        let cbor = json_to_cbor(val);
3782        assert!(matches!(cbor, ciborium::Value::Integer(_)));
3783    }
3784
3785    #[test]
3786    fn json_to_cbor_float() {
3787        let val = serde_json::json!(98.6);
3788        let cbor = json_to_cbor(val);
3789        assert!(matches!(cbor, ciborium::Value::Float(_)));
3790    }
3791
3792    #[test]
3793    fn json_to_cbor_string() {
3794        let val = serde_json::json!("hello");
3795        let cbor = json_to_cbor(val);
3796        assert!(matches!(cbor, ciborium::Value::Text(s) if s == "hello"));
3797    }
3798
3799    #[test]
3800    fn json_to_cbor_array() {
3801        let val = serde_json::json!([1, 2, 3]);
3802        let cbor = json_to_cbor(val);
3803        assert!(matches!(cbor, ciborium::Value::Array(_)));
3804    }
3805
3806    #[test]
3807    fn json_to_cbor_object() {
3808        let val = serde_json::json!({"key": "value"});
3809        let cbor = json_to_cbor(val);
3810        assert!(matches!(cbor, ciborium::Value::Map(_)));
3811    }
3812
3813    #[test]
3814    fn json_to_cbor_u64_fallback_to_float() {
3815        // A number that is not i64 but is u64 → falls back to float
3816        // (JSON numbers outside i64 range)
3817        let val = serde_json::json!(18446744073709551615u64);
3818        let cbor = json_to_cbor(val);
3819        // This should be either Integer or Float depending on serde_json parsing
3820        assert!(!matches!(cbor, ciborium::Value::Null));
3821    }
3822
3823    // ── resolve helpers ──
3824
3825    #[test]
3826    fn resolve_in_btree_skip_reserved_blocks_reserved() {
3827        let mut map = BTreeMap::new();
3828        map.insert("_reserved_".into(), ciborium::Value::Text("secret".into()));
3829        assert!(resolve_in_btree_skip_reserved(&map, &["_reserved_"]).is_none());
3830    }
3831
3832    #[test]
3833    fn resolve_in_btree_empty_parts() {
3834        let map = BTreeMap::new();
3835        assert!(resolve_in_btree(&map, &[]).is_none());
3836    }
3837
3838    #[test]
3839    fn resolve_in_btree_skip_reserved_empty_parts() {
3840        let map = BTreeMap::new();
3841        assert!(resolve_in_btree_skip_reserved(&map, &[]).is_none());
3842    }
3843
3844    // ── validate FFI ──
3845
3846    #[test]
3847    fn parse_validate_options_default() {
3848        let opts = match super::parse_validate_options(ptr::null(), 0) {
3849            Ok(opts) => opts,
3850            Err((_code, msg)) => panic!("expected default options, got error: {msg}"),
3851        };
3852        assert_eq!(opts.max_level, ValidationLevel::Integrity);
3853        assert!(!opts.check_canonical);
3854        assert!(!opts.checksum_only);
3855    }
3856
3857    #[test]
3858    fn parse_validate_options_quick() {
3859        let level = CString::new("quick").unwrap();
3860        let opts = match super::parse_validate_options(level.as_ptr(), 0) {
3861            Ok(opts) => opts,
3862            Err((_code, msg)) => panic!("expected quick options, got error: {msg}"),
3863        };
3864        assert_eq!(opts.max_level, ValidationLevel::Structure);
3865    }
3866
3867    #[test]
3868    fn parse_validate_options_full_canonical() {
3869        let level = CString::new("full").unwrap();
3870        let opts = match super::parse_validate_options(level.as_ptr(), 1) {
3871            Ok(opts) => opts,
3872            Err((_code, msg)) => panic!("expected full options, got error: {msg}"),
3873        };
3874        assert_eq!(opts.max_level, ValidationLevel::Fidelity);
3875        assert!(opts.check_canonical);
3876    }
3877
3878    #[test]
3879    fn parse_validate_options_unknown_level() {
3880        let level = CString::new("bogus").unwrap();
3881        let result = super::parse_validate_options(level.as_ptr(), 0);
3882        assert!(result.is_err());
3883    }
3884
3885    #[test]
3886    fn parse_validate_options_checksum() {
3887        let level = CString::new("checksum").unwrap();
3888        let opts = match super::parse_validate_options(level.as_ptr(), 0) {
3889            Ok(opts) => opts,
3890            Err((_code, msg)) => panic!("expected checksum options, got error: {msg}"),
3891        };
3892        assert_eq!(opts.max_level, ValidationLevel::Integrity);
3893        assert!(opts.checksum_only);
3894    }
3895
3896    // ── tgm_validate end-to-end ──
3897
3898    fn encode_test_message() -> Vec<u8> {
3899        let meta = GlobalMetadata::default();
3900        let desc = DataObjectDescriptor {
3901            obj_type: "ntensor".to_string(),
3902            ndim: 1,
3903            shape: vec![4],
3904            strides: vec![1],
3905            dtype: tensogram::Dtype::Float32,
3906            byte_order: tensogram::ByteOrder::native(),
3907            encoding: "none".to_string(),
3908            filter: "none".to_string(),
3909            compression: "none".to_string(),
3910            params: BTreeMap::new(),
3911            masks: None,
3912        };
3913        let data: Vec<u8> = [1.0f32, 2.0, 3.0, 4.0]
3914            .iter()
3915            .flat_map(|v| v.to_ne_bytes())
3916            .collect();
3917        tensogram::encode(&meta, &[(&desc, data.as_slice())], &Default::default()).unwrap()
3918    }
3919
3920    #[test]
3921    fn tgm_validate_valid_message() {
3922        let msg = encode_test_message();
3923        let mut out = super::TgmBytes {
3924            data: ptr::null_mut(),
3925            len: 0,
3926        };
3927        let err = super::tgm_validate(msg.as_ptr(), msg.len(), ptr::null(), 0, &mut out);
3928        assert!(matches!(err, super::TgmError::Ok));
3929        assert!(!out.data.is_null());
3930        assert!(out.len > 0);
3931        let json_str =
3932            unsafe { std::str::from_utf8(std::slice::from_raw_parts(out.data, out.len)).unwrap() };
3933        assert!(json_str.contains("\"issues\":[]"));
3934        assert!(json_str.contains("\"object_count\":1"));
3935        super::tgm_bytes_free(out);
3936    }
3937
3938    #[test]
3939    fn tgm_validate_empty_buffer() {
3940        let mut out = super::TgmBytes {
3941            data: ptr::null_mut(),
3942            len: 0,
3943        };
3944        let err = super::tgm_validate(ptr::null(), 0, ptr::null(), 0, &mut out);
3945        assert!(matches!(err, super::TgmError::Ok));
3946        let json_str =
3947            unsafe { std::str::from_utf8(std::slice::from_raw_parts(out.data, out.len)).unwrap() };
3948        assert!(json_str.contains("\"buffer_too_short\""));
3949        super::tgm_bytes_free(out);
3950    }
3951
3952    #[test]
3953    fn tgm_validate_invalid_level() {
3954        let msg = encode_test_message();
3955        let level = CString::new("bogus").unwrap();
3956        let mut out = super::TgmBytes {
3957            data: ptr::null_mut(),
3958            len: 0,
3959        };
3960        let err = super::tgm_validate(msg.as_ptr(), msg.len(), level.as_ptr(), 0, &mut out);
3961        assert!(matches!(err, super::TgmError::InvalidArg));
3962    }
3963
3964    #[test]
3965    fn tgm_validate_null_out() {
3966        let msg = encode_test_message();
3967        let err = super::tgm_validate(msg.as_ptr(), msg.len(), ptr::null(), 0, ptr::null_mut());
3968        assert!(matches!(err, super::TgmError::InvalidArg));
3969    }
3970
3971    #[test]
3972    fn tgm_validate_file_nonexistent() {
3973        let path = CString::new("/nonexistent/path/to/file.tgm").unwrap();
3974        let mut out = super::TgmBytes {
3975            data: ptr::null_mut(),
3976            len: 0,
3977        };
3978        let err = super::tgm_validate_file(path.as_ptr(), ptr::null(), 0, &mut out);
3979        assert!(matches!(err, super::TgmError::Io));
3980    }
3981
3982    #[test]
3983    fn tgm_validate_file_null_out() {
3984        let path = CString::new("/tmp/dummy.tgm").unwrap();
3985        let err = super::tgm_validate_file(path.as_ptr(), ptr::null(), 0, ptr::null_mut());
3986        assert!(matches!(err, super::TgmError::InvalidArg));
3987    }
3988
3989    #[test]
3990    fn tgm_validate_file_invalid_level() {
3991        let path = CString::new("/tmp/dummy.tgm").unwrap();
3992        let level = CString::new("bogus").unwrap();
3993        let mut out = super::TgmBytes {
3994            data: ptr::null_mut(),
3995            len: 0,
3996        };
3997        let err = super::tgm_validate_file(path.as_ptr(), level.as_ptr(), 0, &mut out);
3998        assert!(matches!(err, super::TgmError::InvalidArg));
3999    }
4000
4001    // =====================================================================
4002    // FFI round-trip tests — exercise #[no_mangle] extern "C" functions
4003    // =====================================================================
4004
4005    /// Helper: build a JSON metadata string and raw data for a single float32
4006    /// tensor, encode via `tgm_encode`, and return the encoded bytes.
4007    fn ffi_encode_single_f32_tensor(values: &[f32], extra_json: &str) -> Vec<u8> {
4008        let shape_str = format!("[{}]", values.len());
4009        let json = format!(
4010            r#"{{"version":3,"descriptors":[{{"type":"ntensor","ndim":1,"shape":{shape},"strides":[1],"dtype":"float32","byte_order":"{bo}","encoding":"none","filter":"none","compression":"none"}}]{extra}}}"#,
4011            shape = shape_str,
4012            bo = if cfg!(target_endian = "little") {
4013                "little"
4014            } else {
4015                "big"
4016            },
4017            extra = if extra_json.is_empty() {
4018                String::new()
4019            } else {
4020                format!(",{extra_json}")
4021            },
4022        );
4023
4024        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
4025        let c_json = CString::new(json).unwrap();
4026        let data_ptr: *const u8 = data.as_ptr();
4027        let data_len: usize = data.len();
4028
4029        let mut out = super::TgmBytes {
4030            data: ptr::null_mut(),
4031            len: 0,
4032        };
4033
4034        let err = super::tgm_encode(
4035            c_json.as_ptr(),
4036            &data_ptr as *const *const u8,
4037            &data_len as *const usize,
4038            1,
4039            ptr::null(), // no hash
4040            0,           // threads
4041            &mut out,
4042        );
4043        assert!(matches!(err, super::TgmError::Ok), "tgm_encode failed");
4044        assert!(!out.data.is_null());
4045        assert!(out.len > 0);
4046
4047        let encoded = unsafe { slice::from_raw_parts(out.data, out.len) }.to_vec();
4048        super::tgm_bytes_free(out);
4049        encoded
4050    }
4051
4052    /// Helper: encode with hash enabled.
4053    fn ffi_encode_with_hash(values: &[f32]) -> Vec<u8> {
4054        let shape_str = format!("[{}]", values.len());
4055        let json = format!(
4056            r#"{{"version":3,"descriptors":[{{"type":"ntensor","ndim":1,"shape":{shape},"strides":[1],"dtype":"float32","byte_order":"{bo}","encoding":"none","filter":"none","compression":"none"}}]}}"#,
4057            shape = shape_str,
4058            bo = if cfg!(target_endian = "little") {
4059                "little"
4060            } else {
4061                "big"
4062            },
4063        );
4064
4065        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
4066        let c_json = CString::new(json).unwrap();
4067        let hash_algo = CString::new("xxh3").unwrap();
4068        let data_ptr: *const u8 = data.as_ptr();
4069        let data_len: usize = data.len();
4070
4071        let mut out = super::TgmBytes {
4072            data: ptr::null_mut(),
4073            len: 0,
4074        };
4075
4076        let err = super::tgm_encode(
4077            c_json.as_ptr(),
4078            &data_ptr as *const *const u8,
4079            &data_len as *const usize,
4080            1,
4081            hash_algo.as_ptr(),
4082            0,
4083            &mut out,
4084        );
4085        assert!(
4086            matches!(err, super::TgmError::Ok),
4087            "tgm_encode with hash failed"
4088        );
4089
4090        let encoded = unsafe { slice::from_raw_parts(out.data, out.len) }.to_vec();
4091        super::tgm_bytes_free(out);
4092        encoded
4093    }
4094
4095    // ── tgm_encode / tgm_decode round-trip ──
4096
4097    #[test]
4098    fn ffi_encode_decode_round_trip() {
4099        let values = [1.0f32, 2.0, 3.0, 4.0];
4100        let encoded = ffi_encode_single_f32_tensor(&values, "");
4101
4102        // Decode
4103        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4104        let err = super::tgm_decode(
4105            encoded.as_ptr(),
4106            encoded.len(),
4107            0, // no native byte order rewrite
4108            0, // threads
4109            0, // verify_hash
4110            &mut msg,
4111        );
4112        assert!(matches!(err, super::TgmError::Ok));
4113        assert!(!msg.is_null());
4114
4115        // Message-level accessors
4116        assert_eq!(super::tgm_message_version(msg), 3);
4117        assert_eq!(super::tgm_message_num_objects(msg), 1);
4118        assert_eq!(super::tgm_message_num_decoded(msg), 1);
4119
4120        // Object-level accessors
4121        assert_eq!(super::tgm_object_ndim(msg, 0), 1);
4122
4123        let shape_ptr = super::tgm_object_shape(msg, 0);
4124        assert!(!shape_ptr.is_null());
4125        assert_eq!(unsafe { *shape_ptr }, 4);
4126
4127        let strides_ptr = super::tgm_object_strides(msg, 0);
4128        assert!(!strides_ptr.is_null());
4129        assert_eq!(unsafe { *strides_ptr }, 1);
4130
4131        // dtype string
4132        let dtype_ptr = super::tgm_object_dtype(msg, 0);
4133        assert!(!dtype_ptr.is_null());
4134        let dtype_str = unsafe { CStr::from_ptr(dtype_ptr) }.to_str().unwrap();
4135        assert_eq!(dtype_str, "float32");
4136
4137        // type string
4138        let type_ptr = super::tgm_object_type(msg, 0);
4139        assert!(!type_ptr.is_null());
4140        let type_str = unsafe { CStr::from_ptr(type_ptr) }.to_str().unwrap();
4141        assert_eq!(type_str, "ntensor");
4142
4143        // byte_order string
4144        let bo_ptr = super::tgm_object_byte_order(msg, 0);
4145        assert!(!bo_ptr.is_null());
4146        let bo_str = unsafe { CStr::from_ptr(bo_ptr) }.to_str().unwrap();
4147        assert!(bo_str == "little" || bo_str == "big");
4148
4149        // filter string
4150        let filter_ptr = super::tgm_object_filter(msg, 0);
4151        assert!(!filter_ptr.is_null());
4152        let filter_str = unsafe { CStr::from_ptr(filter_ptr) }.to_str().unwrap();
4153        assert_eq!(filter_str, "none");
4154
4155        // compression string
4156        let comp_ptr = super::tgm_object_compression(msg, 0);
4157        assert!(!comp_ptr.is_null());
4158        let comp_str = unsafe { CStr::from_ptr(comp_ptr) }.to_str().unwrap();
4159        assert_eq!(comp_str, "none");
4160
4161        // encoding string
4162        let enc_ptr = super::tgm_payload_encoding(msg, 0);
4163        assert!(!enc_ptr.is_null());
4164        let enc_str = unsafe { CStr::from_ptr(enc_ptr) }.to_str().unwrap();
4165        assert_eq!(enc_str, "none");
4166
4167        // decoded data
4168        let mut data_len: usize = 0;
4169        let data_ptr = super::tgm_object_data(msg, 0, &mut data_len);
4170        assert!(!data_ptr.is_null());
4171        assert_eq!(data_len, 16); // 4 × 4 bytes
4172
4173        let decoded_bytes = unsafe { slice::from_raw_parts(data_ptr, data_len) };
4174        let decoded_values: Vec<f32> = decoded_bytes
4175            .chunks_exact(4)
4176            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
4177            .collect();
4178        assert_eq!(decoded_values, values);
4179
4180        super::tgm_message_free(msg);
4181    }
4182
4183    #[test]
4184    fn ffi_encode_decode_with_hash() {
4185        let values = [10.0f32, 20.0, 30.0];
4186        let encoded = ffi_encode_with_hash(&values);
4187
4188        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4189        let err = super::tgm_decode(
4190            encoded.as_ptr(),
4191            encoded.len(),
4192            0,
4193            0, // threads
4194            0, // verify_hash
4195            &mut msg,
4196        );
4197        assert!(matches!(err, super::TgmError::Ok));
4198
4199        // Hash should be present
4200        assert_eq!(super::tgm_payload_has_hash(msg, 0), 1);
4201
4202        let ht_ptr = super::tgm_object_hash_type(msg, 0);
4203        assert!(!ht_ptr.is_null());
4204        let ht_str = unsafe { CStr::from_ptr(ht_ptr) }.to_str().unwrap();
4205        assert_eq!(ht_str, "xxh3");
4206
4207        let hv_ptr = super::tgm_object_hash_value(msg, 0);
4208        assert!(!hv_ptr.is_null());
4209        let hv_str = unsafe { CStr::from_ptr(hv_ptr) }.to_str().unwrap();
4210        assert!(!hv_str.is_empty());
4211
4212        super::tgm_message_free(msg);
4213    }
4214
4215    #[test]
4216    fn ffi_encode_decode_no_hash() {
4217        let values = [5.0f32];
4218        let encoded = ffi_encode_single_f32_tensor(&values, "");
4219
4220        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4221        let err = super::tgm_decode(encoded.as_ptr(), encoded.len(), 0, 0, 0, &mut msg);
4222        assert!(matches!(err, super::TgmError::Ok));
4223
4224        assert_eq!(super::tgm_payload_has_hash(msg, 0), 0);
4225        assert!(super::tgm_object_hash_type(msg, 0).is_null());
4226        assert!(super::tgm_object_hash_value(msg, 0).is_null());
4227
4228        super::tgm_message_free(msg);
4229    }
4230
4231    // ── verify_hash on the FFI surface ───────────────────────────────
4232
4233    #[test]
4234    fn ffi_decode_verify_hash_succeeds_on_hashed_message() {
4235        // Cell B: hashed message + verify_hash=1 → Ok.
4236        let encoded = ffi_encode_with_hash(&[1.0f32, 2.0]);
4237        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4238        let err = super::tgm_decode(
4239            encoded.as_ptr(),
4240            encoded.len(),
4241            0,
4242            0,
4243            1, // verify_hash
4244            &mut msg,
4245        );
4246        assert!(matches!(err, super::TgmError::Ok));
4247        assert!(!msg.is_null());
4248        super::tgm_message_free(msg);
4249    }
4250
4251    #[test]
4252    fn ffi_decode_verify_hash_returns_missing_hash_on_unhashed_message() {
4253        // Cell C: unhashed message + verify_hash=1 → MissingHash.
4254        let encoded = ffi_encode_single_f32_tensor(&[5.0f32], "");
4255        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4256        let err = super::tgm_decode(
4257            encoded.as_ptr(),
4258            encoded.len(),
4259            0,
4260            0,
4261            1, // verify_hash
4262            &mut msg,
4263        );
4264        assert!(
4265            matches!(err, super::TgmError::MissingHash),
4266            "expected MissingHash, got error code {}",
4267            err as i32
4268        );
4269        let last = unsafe { CStr::from_ptr(super::tgm_last_error()) }
4270            .to_str()
4271            .unwrap();
4272        assert!(
4273            last.contains("object 0"),
4274            "last error should name the offending object: {last}"
4275        );
4276        // No message handle was returned — nothing to free.
4277    }
4278
4279    #[test]
4280    fn ffi_decode_verify_hash_returns_hash_mismatch_on_tampered_slot() {
4281        // Cell D: hashed message with a flipped inline-hash-slot
4282        // byte + verify_hash=1 → HashMismatch.  Tampering the
4283        // slot (rather than the body) keeps the rest of the frame
4284        // structurally valid so the CBOR descriptor parses
4285        // cleanly — only the inline-hash check fires.  See
4286        // `decode_verify_hash.rs` (Rust core) for the cell E
4287        // variant where the payload itself is tampered.
4288        let mut encoded = ffi_encode_with_hash(&[10.0f32, 20.0, 30.0]);
4289        // Locate the message footer's preceding object frame and
4290        // flip a byte of the 8-byte hash slot, which lives at
4291        // `frame_end - 12` for every frame.  Walking from the
4292        // preamble end (24) we find the first NTensorFrame.
4293        let frame_start = {
4294            let mut pos = 24usize;
4295            loop {
4296                assert!(pos + 16 <= encoded.len(), "frame not found");
4297                if &encoded[pos..pos + 2] == b"FR"
4298                    && tensogram::wire::FrameHeader::read_from(&encoded[pos..])
4299                        .map(|fh| fh.frame_type.is_data_object())
4300                        .unwrap_or(false)
4301                {
4302                    break pos;
4303                }
4304                pos += 1;
4305            }
4306        };
4307        let fh = tensogram::wire::FrameHeader::read_from(&encoded[frame_start..]).unwrap();
4308        let frame_end = frame_start + fh.total_length as usize;
4309        let slot_byte = frame_end - 12; // first byte of the 8-byte slot
4310        encoded[slot_byte] ^= 0xFF;
4311
4312        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4313        let err = super::tgm_decode(
4314            encoded.as_ptr(),
4315            encoded.len(),
4316            0,
4317            0,
4318            1, // verify_hash
4319            &mut msg,
4320        );
4321        assert!(
4322            matches!(err, super::TgmError::HashMismatch),
4323            "expected HashMismatch, got error code {}",
4324            err as i32
4325        );
4326        let last = unsafe { CStr::from_ptr(super::tgm_last_error()) }
4327            .to_str()
4328            .unwrap();
4329        assert!(
4330            last.contains("object 0"),
4331            "last error should name the offending object: {last}"
4332        );
4333    }
4334
4335    #[test]
4336    fn ffi_decode_verify_hash_off_silently_decodes_unhashed_message() {
4337        // Cell A complement — no verify, unhashed message decodes
4338        // cleanly (the existing `ffi_encode_decode_no_hash` test
4339        // covers this implicitly; here we add an explicit
4340        // verify_hash=0 assertion to pin the default).
4341        let encoded = ffi_encode_single_f32_tensor(&[5.0f32], "");
4342        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4343        let err = super::tgm_decode(
4344            encoded.as_ptr(),
4345            encoded.len(),
4346            0,
4347            0,
4348            0, // verify_hash off
4349            &mut msg,
4350        );
4351        assert!(matches!(err, super::TgmError::Ok));
4352        super::tgm_message_free(msg);
4353    }
4354
4355    #[test]
4356    fn ffi_decode_object_verify_hash_returns_missing_hash_on_unhashed() {
4357        // Cell C for tgm_decode_object.
4358        let encoded = ffi_encode_single_f32_tensor(&[5.0f32], "");
4359        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4360        let err = super::tgm_decode_object(
4361            encoded.as_ptr(),
4362            encoded.len(),
4363            0,
4364            0,
4365            0,
4366            1, // verify_hash
4367            &mut msg,
4368        );
4369        assert!(
4370            matches!(err, super::TgmError::MissingHash),
4371            "expected MissingHash, got error code {}",
4372            err as i32
4373        );
4374    }
4375
4376    #[test]
4377    fn ffi_encode_with_extra_metadata() {
4378        let values = [1.0f32, 2.0];
4379        let encoded = ffi_encode_single_f32_tensor(&values, r#""source":"test_source","count":42"#);
4380
4381        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4382        let err = super::tgm_decode(encoded.as_ptr(), encoded.len(), 0, 0, 0, &mut msg);
4383        assert!(matches!(err, super::TgmError::Ok));
4384
4385        // Extract metadata from decoded message
4386        let mut meta: *mut super::TgmMetadata = ptr::null_mut();
4387        let err = super::tgm_message_metadata(msg, &mut meta);
4388        assert!(matches!(err, super::TgmError::Ok));
4389
4390        let key = CString::new("source").unwrap();
4391        let val_ptr = super::tgm_metadata_get_string(meta, key.as_ptr());
4392        assert!(!val_ptr.is_null());
4393        let val_str = unsafe { CStr::from_ptr(val_ptr) }.to_str().unwrap();
4394        assert_eq!(val_str, "test_source");
4395
4396        let key_count = CString::new("count").unwrap();
4397        let val_int = super::tgm_metadata_get_int(meta, key_count.as_ptr(), -1);
4398        assert_eq!(val_int, 42);
4399
4400        super::tgm_metadata_free(meta);
4401        super::tgm_message_free(msg);
4402    }
4403
4404    // ── tgm_encode null/error paths ──
4405
4406    #[test]
4407    fn ffi_encode_null_json() {
4408        let mut out = super::TgmBytes {
4409            data: ptr::null_mut(),
4410            len: 0,
4411        };
4412        let err = super::tgm_encode(
4413            ptr::null(),
4414            ptr::null(),
4415            ptr::null(),
4416            0,
4417            ptr::null(),
4418            0,
4419            &mut out,
4420        );
4421        assert!(matches!(err, super::TgmError::InvalidArg));
4422    }
4423
4424    #[test]
4425    fn ffi_encode_null_out() {
4426        let json = CString::new(r#"{"version":3,"descriptors":[]}"#).unwrap();
4427        let err = super::tgm_encode(
4428            json.as_ptr(),
4429            ptr::null(),
4430            ptr::null(),
4431            0,
4432            ptr::null(),
4433            0,
4434            ptr::null_mut(),
4435        );
4436        assert!(matches!(err, super::TgmError::InvalidArg));
4437    }
4438
4439    #[test]
4440    fn ffi_encode_descriptor_count_mismatch() {
4441        // JSON says 0 descriptors, but num_objects = 1
4442        let json = CString::new(r#"{"version":3,"descriptors":[]}"#).unwrap();
4443        let data: [u8; 4] = [0; 4];
4444        let data_ptr: *const u8 = data.as_ptr();
4445        let data_len: usize = 4;
4446        let mut out = super::TgmBytes {
4447            data: ptr::null_mut(),
4448            len: 0,
4449        };
4450        let err = super::tgm_encode(
4451            json.as_ptr(),
4452            &data_ptr as *const *const u8,
4453            &data_len as *const usize,
4454            1, // mismatch!
4455            ptr::null(),
4456            0, // threads
4457            &mut out,
4458        );
4459        assert!(matches!(err, super::TgmError::InvalidArg));
4460    }
4461
4462    #[test]
4463    fn ffi_encode_invalid_json() {
4464        let json = CString::new("not valid json").unwrap();
4465        let mut out = super::TgmBytes {
4466            data: ptr::null_mut(),
4467            len: 0,
4468        };
4469        let err = super::tgm_encode(
4470            json.as_ptr(),
4471            ptr::null(),
4472            ptr::null(),
4473            0,
4474            ptr::null(),
4475            0,
4476            &mut out,
4477        );
4478        assert!(matches!(err, super::TgmError::Metadata));
4479    }
4480
4481    // ── tgm_decode null/error paths ──
4482
4483    #[test]
4484    fn ffi_decode_null_buf() {
4485        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4486        let err = super::tgm_decode(ptr::null(), 0, 0, 0, 0, &mut msg);
4487        assert!(matches!(err, super::TgmError::InvalidArg));
4488    }
4489
4490    #[test]
4491    fn ffi_decode_null_out() {
4492        let data = [0u8; 10];
4493        let err = super::tgm_decode(data.as_ptr(), data.len(), 0, 0, 0, ptr::null_mut());
4494        assert!(matches!(err, super::TgmError::InvalidArg));
4495    }
4496
4497    #[test]
4498    fn ffi_decode_garbage_data() {
4499        let data = [0u8; 10];
4500        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4501        let err = super::tgm_decode(data.as_ptr(), data.len(), 0, 0, 0, &mut msg);
4502        // Should fail with a framing or other error
4503        assert!(!matches!(err, super::TgmError::Ok));
4504    }
4505
4506    // ── tgm_decode_metadata round-trip ──
4507
4508    #[test]
4509    fn ffi_decode_metadata_round_trip() {
4510        let values = [1.0f32, 2.0];
4511        let encoded = ffi_encode_single_f32_tensor(&values, r#""source":"meta_test""#);
4512
4513        let mut meta: *mut super::TgmMetadata = ptr::null_mut();
4514        let err = super::tgm_decode_metadata(encoded.as_ptr(), encoded.len(), &mut meta);
4515        assert!(matches!(err, super::TgmError::Ok));
4516        assert!(!meta.is_null());
4517
4518        // Version
4519        assert_eq!(super::tgm_metadata_version(meta), 3);
4520
4521        // num_objects
4522        assert_eq!(super::tgm_metadata_num_objects(meta), 1);
4523
4524        // String lookup
4525        let key = CString::new("source").unwrap();
4526        let val_ptr = super::tgm_metadata_get_string(meta, key.as_ptr());
4527        assert!(!val_ptr.is_null());
4528        let val_str = unsafe { CStr::from_ptr(val_ptr) }.to_str().unwrap();
4529        assert_eq!(val_str, "meta_test");
4530
4531        // Missing key returns null
4532        let bad_key = CString::new("nonexistent").unwrap();
4533        assert!(super::tgm_metadata_get_string(meta, bad_key.as_ptr()).is_null());
4534
4535        // Int with default
4536        let bad_key2 = CString::new("missing_int").unwrap();
4537        assert_eq!(
4538            super::tgm_metadata_get_int(meta, bad_key2.as_ptr(), -999),
4539            -999
4540        );
4541
4542        // Float with default
4543        let bad_key3 = CString::new("missing_float").unwrap();
4544        let fval = super::tgm_metadata_get_float(meta, bad_key3.as_ptr(), 3.25);
4545        assert!((fval - 3.25).abs() < f64::EPSILON);
4546
4547        super::tgm_metadata_free(meta);
4548    }
4549
4550    #[test]
4551    fn ffi_decode_metadata_null_args() {
4552        let mut meta: *mut super::TgmMetadata = ptr::null_mut();
4553        let err = super::tgm_decode_metadata(ptr::null(), 0, &mut meta);
4554        assert!(matches!(err, super::TgmError::InvalidArg));
4555
4556        let data = [0u8; 10];
4557        let err = super::tgm_decode_metadata(data.as_ptr(), data.len(), ptr::null_mut());
4558        assert!(matches!(err, super::TgmError::InvalidArg));
4559    }
4560
4561    // ── tgm_metadata null pointer safety ──
4562
4563    #[test]
4564    fn ffi_metadata_accessors_null_handle() {
4565        assert_eq!(super::tgm_metadata_version(ptr::null()), 0);
4566        assert_eq!(super::tgm_metadata_num_objects(ptr::null()), 0);
4567        assert!(super::tgm_metadata_get_string(ptr::null(), ptr::null()).is_null());
4568        assert_eq!(
4569            super::tgm_metadata_get_int(ptr::null(), ptr::null(), -1),
4570            -1
4571        );
4572        assert_eq!(
4573            super::tgm_metadata_get_float(ptr::null(), ptr::null(), 1.5),
4574            1.5
4575        );
4576    }
4577
4578    #[test]
4579    fn ffi_metadata_get_string_null_key() {
4580        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
4581        let mut meta: *mut super::TgmMetadata = ptr::null_mut();
4582        let err = super::tgm_decode_metadata(encoded.as_ptr(), encoded.len(), &mut meta);
4583        assert!(matches!(err, super::TgmError::Ok));
4584
4585        assert!(super::tgm_metadata_get_string(meta, ptr::null()).is_null());
4586        assert_eq!(super::tgm_metadata_get_int(meta, ptr::null(), -1), -1);
4587        assert_eq!(super::tgm_metadata_get_float(meta, ptr::null(), 1.5), 1.5);
4588
4589        super::tgm_metadata_free(meta);
4590    }
4591
4592    #[test]
4593    fn ffi_metadata_get_version_via_string() {
4594        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
4595        let mut meta: *mut super::TgmMetadata = ptr::null_mut();
4596        let err = super::tgm_decode_metadata(encoded.as_ptr(), encoded.len(), &mut meta);
4597        assert!(matches!(err, super::TgmError::Ok));
4598
4599        let key = CString::new("version").unwrap();
4600        let val_ptr = super::tgm_metadata_get_string(meta, key.as_ptr());
4601        assert!(!val_ptr.is_null());
4602        let val_str = unsafe { CStr::from_ptr(val_ptr) }.to_str().unwrap();
4603        assert_eq!(val_str, "3");
4604
4605        let ival = super::tgm_metadata_get_int(meta, key.as_ptr(), -1);
4606        assert_eq!(ival, 3);
4607
4608        super::tgm_metadata_free(meta);
4609    }
4610
4611    #[test]
4612    fn ffi_metadata_get_float_value() {
4613        let values = [1.0f32];
4614        let encoded = ffi_encode_single_f32_tensor(&values, r#""temperature":98.6"#);
4615
4616        let mut meta: *mut super::TgmMetadata = ptr::null_mut();
4617        let err = super::tgm_decode_metadata(encoded.as_ptr(), encoded.len(), &mut meta);
4618        assert!(matches!(err, super::TgmError::Ok));
4619
4620        let key = CString::new("temperature").unwrap();
4621        let fval = super::tgm_metadata_get_float(meta, key.as_ptr(), 0.0);
4622        assert!((fval - 98.6).abs() < 0.01);
4623
4624        super::tgm_metadata_free(meta);
4625    }
4626
4627    // ── tgm_decode_object ──
4628
4629    #[test]
4630    fn ffi_decode_object_round_trip() {
4631        let values = [10.0f32, 20.0, 30.0, 40.0];
4632        let encoded = ffi_encode_single_f32_tensor(&values, "");
4633
4634        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4635        let err = super::tgm_decode_object(
4636            encoded.as_ptr(),
4637            encoded.len(),
4638            0, // index
4639            0, // native byte order
4640            0, // threads
4641            0, // verify_hash
4642            &mut msg,
4643        );
4644        assert!(matches!(err, super::TgmError::Ok));
4645        assert!(!msg.is_null());
4646
4647        // Single object in result
4648        assert_eq!(super::tgm_message_num_objects(msg), 1);
4649        assert_eq!(super::tgm_object_ndim(msg, 0), 1);
4650
4651        let mut data_len: usize = 0;
4652        let data_ptr = super::tgm_object_data(msg, 0, &mut data_len);
4653        assert!(!data_ptr.is_null());
4654        let decoded_bytes = unsafe { slice::from_raw_parts(data_ptr, data_len) };
4655        let decoded_values: Vec<f32> = decoded_bytes
4656            .chunks_exact(4)
4657            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
4658            .collect();
4659        assert_eq!(decoded_values, values);
4660
4661        super::tgm_message_free(msg);
4662    }
4663
4664    #[test]
4665    fn ffi_decode_object_out_of_range() {
4666        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
4667        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4668        let err = super::tgm_decode_object(
4669            encoded.as_ptr(),
4670            encoded.len(),
4671            999, // out of range
4672            0,
4673            0, // threads
4674            0, // verify_hash
4675            &mut msg,
4676        );
4677        assert!(!matches!(err, super::TgmError::Ok));
4678    }
4679
4680    #[test]
4681    fn ffi_decode_object_null_args() {
4682        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4683        let err = super::tgm_decode_object(ptr::null(), 0, 0, 0, 0, 0, &mut msg);
4684        assert!(matches!(err, super::TgmError::InvalidArg));
4685
4686        let data = [0u8; 10];
4687        let err = super::tgm_decode_object(data.as_ptr(), data.len(), 0, 0, 0, 0, ptr::null_mut());
4688        assert!(matches!(err, super::TgmError::InvalidArg));
4689    }
4690
4691    // ── tgm_message_metadata ──
4692
4693    #[test]
4694    fn ffi_message_metadata_null_args() {
4695        let err = super::tgm_message_metadata(ptr::null(), ptr::null_mut());
4696        assert!(matches!(err, super::TgmError::InvalidArg));
4697
4698        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
4699        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4700        let err = super::tgm_decode(encoded.as_ptr(), encoded.len(), 0, 0, 0, &mut msg);
4701        assert!(matches!(err, super::TgmError::Ok));
4702
4703        let err = super::tgm_message_metadata(msg, ptr::null_mut());
4704        assert!(matches!(err, super::TgmError::InvalidArg));
4705
4706        super::tgm_message_free(msg);
4707    }
4708
4709    // ── tgm_message accessors with null msg ──
4710
4711    #[test]
4712    fn ffi_message_accessors_null_handle() {
4713        assert_eq!(super::tgm_message_version(ptr::null()), 0);
4714        assert_eq!(super::tgm_message_num_objects(ptr::null()), 0);
4715        assert_eq!(super::tgm_message_num_decoded(ptr::null()), 0);
4716        assert_eq!(super::tgm_object_ndim(ptr::null(), 0), 0);
4717        assert!(super::tgm_object_shape(ptr::null(), 0).is_null());
4718        assert!(super::tgm_object_strides(ptr::null(), 0).is_null());
4719        assert!(super::tgm_object_dtype(ptr::null(), 0).is_null());
4720        assert!(super::tgm_object_type(ptr::null(), 0).is_null());
4721        assert!(super::tgm_object_byte_order(ptr::null(), 0).is_null());
4722        assert!(super::tgm_object_filter(ptr::null(), 0).is_null());
4723        assert!(super::tgm_object_compression(ptr::null(), 0).is_null());
4724        assert!(super::tgm_payload_encoding(ptr::null(), 0).is_null());
4725        assert_eq!(super::tgm_payload_has_hash(ptr::null(), 0), 0);
4726        assert!(super::tgm_object_hash_type(ptr::null(), 0).is_null());
4727        assert!(super::tgm_object_hash_value(ptr::null(), 0).is_null());
4728
4729        let mut data_len: usize = 99;
4730        let data_ptr = super::tgm_object_data(ptr::null(), 0, &mut data_len);
4731        assert!(data_ptr.is_null());
4732        assert_eq!(data_len, 0);
4733    }
4734
4735    // ── tgm_message accessors out-of-bounds index ──
4736
4737    #[test]
4738    fn ffi_message_accessors_out_of_bounds() {
4739        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
4740        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4741        let err = super::tgm_decode(encoded.as_ptr(), encoded.len(), 0, 0, 0, &mut msg);
4742        assert!(matches!(err, super::TgmError::Ok));
4743
4744        // Index 1 does not exist (only index 0)
4745        assert_eq!(super::tgm_object_ndim(msg, 1), 0);
4746        assert!(super::tgm_object_shape(msg, 1).is_null());
4747        assert!(super::tgm_object_strides(msg, 1).is_null());
4748        assert!(super::tgm_object_dtype(msg, 1).is_null());
4749        assert!(super::tgm_object_type(msg, 1).is_null());
4750        assert!(super::tgm_object_byte_order(msg, 1).is_null());
4751        assert!(super::tgm_object_filter(msg, 1).is_null());
4752        assert!(super::tgm_object_compression(msg, 1).is_null());
4753        assert!(super::tgm_payload_encoding(msg, 1).is_null());
4754        assert_eq!(super::tgm_payload_has_hash(msg, 1), 0);
4755        assert!(super::tgm_object_hash_type(msg, 1).is_null());
4756        assert!(super::tgm_object_hash_value(msg, 1).is_null());
4757
4758        let mut data_len: usize = 99;
4759        let data_ptr = super::tgm_object_data(msg, 1, &mut data_len);
4760        assert!(data_ptr.is_null());
4761        assert_eq!(data_len, 0);
4762
4763        super::tgm_message_free(msg);
4764    }
4765
4766    // ── tgm_object_data with null out_len ──
4767
4768    #[test]
4769    fn ffi_object_data_null_out_len() {
4770        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
4771        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4772        let err = super::tgm_decode(encoded.as_ptr(), encoded.len(), 0, 0, 0, &mut msg);
4773        assert!(matches!(err, super::TgmError::Ok));
4774
4775        // null out_len should not crash
4776        let data_ptr = super::tgm_object_data(msg, 0, ptr::null_mut());
4777        assert!(!data_ptr.is_null());
4778
4779        super::tgm_message_free(msg);
4780    }
4781
4782    // ── tgm_decode_range ──
4783
4784    #[test]
4785    fn ffi_decode_range_round_trip() {
4786        let values = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
4787        let encoded = ffi_encode_single_f32_tensor(&values, "");
4788
4789        // Request elements [2..5) (3 elements)
4790        let range_offset: u64 = 2;
4791        let range_count: u64 = 3;
4792        let mut out_buf = super::TgmBytes {
4793            data: ptr::null_mut(),
4794            len: 0,
4795        };
4796        let mut out_count: usize = 0;
4797
4798        let err = super::tgm_decode_range(
4799            encoded.as_ptr(),
4800            encoded.len(),
4801            0,
4802            &range_offset as *const u64,
4803            &range_count as *const u64,
4804            1,
4805            0, // no native byte order
4806            0, // threads
4807            1, // join
4808            &mut out_buf,
4809            &mut out_count,
4810        );
4811        assert!(matches!(err, super::TgmError::Ok));
4812        assert_eq!(out_count, 1);
4813        assert!(!out_buf.data.is_null());
4814
4815        let decoded_bytes = unsafe { slice::from_raw_parts(out_buf.data, out_buf.len) };
4816        let decoded_values: Vec<f32> = decoded_bytes
4817            .chunks_exact(4)
4818            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
4819            .collect();
4820        assert_eq!(decoded_values, [3.0, 4.0, 5.0]);
4821
4822        super::tgm_bytes_free(out_buf);
4823    }
4824
4825    #[test]
4826    fn ffi_decode_range_split_mode() {
4827        let values = [10.0f32, 20.0, 30.0, 40.0];
4828        let encoded = ffi_encode_single_f32_tensor(&values, "");
4829
4830        // Two ranges: [0..2), [2..4)
4831        let range_offsets: [u64; 2] = [0, 2];
4832        let range_counts: [u64; 2] = [2, 2];
4833        let mut out_bufs = [
4834            super::TgmBytes {
4835                data: ptr::null_mut(),
4836                len: 0,
4837            },
4838            super::TgmBytes {
4839                data: ptr::null_mut(),
4840                len: 0,
4841            },
4842        ];
4843        let mut out_count: usize = 0;
4844
4845        let err = super::tgm_decode_range(
4846            encoded.as_ptr(),
4847            encoded.len(),
4848            0,
4849            range_offsets.as_ptr(),
4850            range_counts.as_ptr(),
4851            2,
4852            0,
4853            0, // threads
4854            0, // split mode (join=0)
4855            out_bufs.as_mut_ptr(),
4856            &mut out_count,
4857        );
4858        assert!(matches!(err, super::TgmError::Ok));
4859        assert_eq!(out_count, 2);
4860
4861        // First range: [10.0, 20.0]
4862        let bytes0 = unsafe { slice::from_raw_parts(out_bufs[0].data, out_bufs[0].len) };
4863        let vals0: Vec<f32> = bytes0
4864            .chunks_exact(4)
4865            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
4866            .collect();
4867        assert_eq!(vals0, [10.0, 20.0]);
4868
4869        // Second range: [30.0, 40.0]
4870        let bytes1 = unsafe { slice::from_raw_parts(out_bufs[1].data, out_bufs[1].len) };
4871        let vals1: Vec<f32> = bytes1
4872            .chunks_exact(4)
4873            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
4874            .collect();
4875        assert_eq!(vals1, [30.0, 40.0]);
4876
4877        // TgmBytes is not Copy, so manually construct values for free
4878        super::tgm_bytes_free(super::TgmBytes {
4879            data: out_bufs[0].data,
4880            len: out_bufs[0].len,
4881        });
4882        super::tgm_bytes_free(super::TgmBytes {
4883            data: out_bufs[1].data,
4884            len: out_bufs[1].len,
4885        });
4886    }
4887
4888    #[test]
4889    fn ffi_decode_range_null_args() {
4890        let mut out_buf = super::TgmBytes {
4891            data: ptr::null_mut(),
4892            len: 0,
4893        };
4894        let mut out_count: usize = 0;
4895
4896        // null buf
4897        let err = super::tgm_decode_range(
4898            ptr::null(),
4899            0,
4900            0,
4901            ptr::null(),
4902            ptr::null(),
4903            0,
4904            0,
4905            0,
4906            0,
4907            &mut out_buf,
4908            &mut out_count,
4909        );
4910        assert!(matches!(err, super::TgmError::InvalidArg));
4911
4912        // null out
4913        let data = [0u8; 10];
4914        let err = super::tgm_decode_range(
4915            data.as_ptr(),
4916            data.len(),
4917            0,
4918            ptr::null(),
4919            ptr::null(),
4920            0,
4921            0,
4922            0,
4923            0,
4924            ptr::null_mut(),
4925            &mut out_count,
4926        );
4927        assert!(matches!(err, super::TgmError::InvalidArg));
4928
4929        // null out_count
4930        let err = super::tgm_decode_range(
4931            data.as_ptr(),
4932            data.len(),
4933            0,
4934            ptr::null(),
4935            ptr::null(),
4936            0,
4937            0,
4938            0,
4939            0,
4940            &mut out_buf,
4941            ptr::null_mut(),
4942        );
4943        assert!(matches!(err, super::TgmError::InvalidArg));
4944    }
4945
4946    #[test]
4947    fn ffi_decode_range_null_ranges_with_nonzero_count() {
4948        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
4949        let mut out_buf = super::TgmBytes {
4950            data: ptr::null_mut(),
4951            len: 0,
4952        };
4953        let mut out_count: usize = 0;
4954
4955        let err = super::tgm_decode_range(
4956            encoded.as_ptr(),
4957            encoded.len(),
4958            0,
4959            ptr::null(), // null ranges_offsets
4960            ptr::null(), // null ranges_counts
4961            1,           // but num_ranges > 0
4962            0,
4963            0, // threads
4964            0,
4965            &mut out_buf,
4966            &mut out_count,
4967        );
4968        assert!(matches!(err, super::TgmError::InvalidArg));
4969    }
4970
4971    // ── tgm_scan ──
4972
4973    #[test]
4974    fn ffi_scan_single_message() {
4975        let encoded = ffi_encode_single_f32_tensor(&[1.0f32, 2.0], "");
4976
4977        let mut result: *mut super::TgmScanResult = ptr::null_mut();
4978        let err = super::tgm_scan(encoded.as_ptr(), encoded.len(), &mut result);
4979        assert!(matches!(err, super::TgmError::Ok));
4980        assert!(!result.is_null());
4981
4982        assert_eq!(super::tgm_scan_count(result), 1);
4983
4984        let entry = super::tgm_scan_entry(result, 0);
4985        assert_eq!(entry.offset, 0);
4986        assert_eq!(entry.length, encoded.len());
4987
4988        // Out of bounds entry returns sentinel (offset=usize::MAX, length=0)
4989        // and sets tgm_last_error
4990        let bad = super::tgm_scan_entry(result, 999);
4991        assert_eq!(bad.offset, usize::MAX);
4992        assert_eq!(bad.length, 0);
4993        let err_ptr = super::tgm_last_error();
4994        assert!(!err_ptr.is_null());
4995        let err_str = unsafe { CStr::from_ptr(err_ptr) }.to_str().unwrap();
4996        assert!(
4997            err_str.contains("out of range"),
4998            "expected OOB error, got: {err_str}"
4999        );
5000
5001        super::tgm_scan_free(result);
5002    }
5003
5004    #[test]
5005    fn ffi_scan_null_args() {
5006        let mut result: *mut super::TgmScanResult = ptr::null_mut();
5007        let err = super::tgm_scan(ptr::null(), 0, &mut result);
5008        assert!(matches!(err, super::TgmError::InvalidArg));
5009
5010        let data = [0u8; 10];
5011        let err = super::tgm_scan(data.as_ptr(), data.len(), ptr::null_mut());
5012        assert!(matches!(err, super::TgmError::InvalidArg));
5013    }
5014
5015    #[test]
5016    fn ffi_scan_null_handle_accessors() {
5017        assert_eq!(super::tgm_scan_count(ptr::null()), 0);
5018        let entry = super::tgm_scan_entry(ptr::null(), 0);
5019        assert_eq!(entry.offset, usize::MAX);
5020        assert_eq!(entry.length, 0);
5021    }
5022
5023    #[test]
5024    fn ffi_scan_concatenated_messages() {
5025        let msg1 = ffi_encode_single_f32_tensor(&[1.0f32], "");
5026        let msg2 = ffi_encode_single_f32_tensor(&[2.0f32], "");
5027        let mut concat = msg1.clone();
5028        concat.extend_from_slice(&msg2);
5029
5030        let mut result: *mut super::TgmScanResult = ptr::null_mut();
5031        let err = super::tgm_scan(concat.as_ptr(), concat.len(), &mut result);
5032        assert!(matches!(err, super::TgmError::Ok));
5033
5034        assert_eq!(super::tgm_scan_count(result), 2);
5035
5036        let e0 = super::tgm_scan_entry(result, 0);
5037        assert_eq!(e0.offset, 0);
5038        assert_eq!(e0.length, msg1.len());
5039
5040        let e1 = super::tgm_scan_entry(result, 1);
5041        assert_eq!(e1.offset, msg1.len());
5042        assert_eq!(e1.length, msg2.len());
5043
5044        super::tgm_scan_free(result);
5045    }
5046
5047    // ── tgm_file_* functions ──
5048
5049    #[test]
5050    fn ffi_file_create_append_count_decode_close() {
5051        let dir = std::env::temp_dir();
5052        let path = dir.join("ffi_test_file.tgm");
5053        let _ = std::fs::remove_file(&path);
5054
5055        let c_path = CString::new(path.to_str().unwrap()).unwrap();
5056
5057        // Create
5058        let mut file: *mut super::TgmFile = ptr::null_mut();
5059        let err = super::tgm_file_create(c_path.as_ptr(), &mut file);
5060        assert!(matches!(err, super::TgmError::Ok));
5061        assert!(!file.is_null());
5062
5063        // Append a message
5064        let values = [10.0f32, 20.0, 30.0];
5065        let shape_str = format!("[{}]", values.len());
5066        let json = format!(
5067            r#"{{"version":3,"descriptors":[{{"type":"ntensor","ndim":1,"shape":{shape},"strides":[1],"dtype":"float32","byte_order":"{bo}","encoding":"none","filter":"none","compression":"none"}}]}}"#,
5068            shape = shape_str,
5069            bo = if cfg!(target_endian = "little") {
5070                "little"
5071            } else {
5072                "big"
5073            },
5074        );
5075        let c_json = CString::new(json).unwrap();
5076        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
5077        let data_ptr: *const u8 = data.as_ptr();
5078        let data_len: usize = data.len();
5079
5080        let err = super::tgm_file_append(
5081            file,
5082            c_json.as_ptr(),
5083            &data_ptr as *const *const u8,
5084            &data_len as *const usize,
5085            1,
5086            ptr::null(),
5087            0,
5088        );
5089        assert!(matches!(err, super::TgmError::Ok));
5090
5091        // Check path accessor
5092        let path_ptr = super::tgm_file_path(file);
5093        assert!(!path_ptr.is_null());
5094        let path_str = unsafe { CStr::from_ptr(path_ptr) }.to_str().unwrap();
5095        assert!(path_str.contains("ffi_test_file.tgm"));
5096
5097        super::tgm_file_close(file);
5098
5099        // Re-open for reading
5100        let mut file2: *mut super::TgmFile = ptr::null_mut();
5101        let err = super::tgm_file_open(c_path.as_ptr(), &mut file2);
5102        assert!(matches!(err, super::TgmError::Ok));
5103
5104        // Message count
5105        let mut count: usize = 0;
5106        let err = super::tgm_file_message_count(file2, &mut count);
5107        assert!(matches!(err, super::TgmError::Ok));
5108        assert_eq!(count, 1);
5109
5110        // Decode message
5111        let mut msg: *mut super::TgmMessage = ptr::null_mut();
5112        let err = super::tgm_file_decode_message(file2, 0, 0, 0, 0, &mut msg);
5113        assert!(matches!(err, super::TgmError::Ok));
5114
5115        assert_eq!(super::tgm_message_num_objects(msg), 1);
5116        let mut data_len2: usize = 0;
5117        let dp = super::tgm_object_data(msg, 0, &mut data_len2);
5118        assert!(!dp.is_null());
5119        let decoded_bytes = unsafe { slice::from_raw_parts(dp, data_len2) };
5120        let decoded_values: Vec<f32> = decoded_bytes
5121            .chunks_exact(4)
5122            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
5123            .collect();
5124        assert_eq!(decoded_values, values);
5125
5126        super::tgm_message_free(msg);
5127
5128        // Read raw message
5129        let mut raw = super::TgmBytes {
5130            data: ptr::null_mut(),
5131            len: 0,
5132        };
5133        let err = super::tgm_file_read_message(file2, 0, &mut raw);
5134        assert!(matches!(err, super::TgmError::Ok));
5135        assert!(!raw.data.is_null());
5136        assert!(raw.len > 0);
5137        super::tgm_bytes_free(raw);
5138
5139        super::tgm_file_close(file2);
5140        let _ = std::fs::remove_file(&path);
5141    }
5142
5143    #[test]
5144    fn ffi_file_open_nonexistent() {
5145        let c_path = CString::new("/nonexistent/file.tgm").unwrap();
5146        let mut file: *mut super::TgmFile = ptr::null_mut();
5147        let err = super::tgm_file_open(c_path.as_ptr(), &mut file);
5148        assert!(!matches!(err, super::TgmError::Ok));
5149    }
5150
5151    #[test]
5152    fn ffi_file_null_args() {
5153        let mut file: *mut super::TgmFile = ptr::null_mut();
5154
5155        // open null path
5156        let err = super::tgm_file_open(ptr::null(), &mut file);
5157        assert!(matches!(err, super::TgmError::InvalidArg));
5158
5159        // open null out
5160        let c_path = CString::new("/tmp/test.tgm").unwrap();
5161        let err = super::tgm_file_open(c_path.as_ptr(), ptr::null_mut());
5162        assert!(matches!(err, super::TgmError::InvalidArg));
5163
5164        // create null path
5165        let err = super::tgm_file_create(ptr::null(), &mut file);
5166        assert!(matches!(err, super::TgmError::InvalidArg));
5167
5168        // create null out
5169        let err = super::tgm_file_create(c_path.as_ptr(), ptr::null_mut());
5170        assert!(matches!(err, super::TgmError::InvalidArg));
5171
5172        // message_count null args
5173        let err = super::tgm_file_message_count(ptr::null_mut(), ptr::null_mut());
5174        assert!(matches!(err, super::TgmError::InvalidArg));
5175
5176        // decode_message null args
5177        let err = super::tgm_file_decode_message(ptr::null_mut(), 0, 0, 0, 0, ptr::null_mut());
5178        assert!(matches!(err, super::TgmError::InvalidArg));
5179
5180        // read_message null args
5181        let err = super::tgm_file_read_message(ptr::null_mut(), 0, ptr::null_mut());
5182        assert!(matches!(err, super::TgmError::InvalidArg));
5183
5184        // append null args
5185        let err = super::tgm_file_append(
5186            ptr::null_mut(),
5187            ptr::null(),
5188            ptr::null(),
5189            ptr::null(),
5190            0,
5191            ptr::null(),
5192            0,
5193        );
5194        assert!(matches!(err, super::TgmError::InvalidArg));
5195
5196        // append_raw null args
5197        let err = super::tgm_file_append_raw(ptr::null_mut(), ptr::null(), 0);
5198        assert!(matches!(err, super::TgmError::InvalidArg));
5199
5200        // path null
5201        assert!(super::tgm_file_path(ptr::null()).is_null());
5202    }
5203
5204    #[test]
5205    fn ffi_file_append_raw_round_trip() {
5206        let dir = std::env::temp_dir();
5207        let path = dir.join("ffi_test_append_raw.tgm");
5208        let _ = std::fs::remove_file(&path);
5209
5210        let c_path = CString::new(path.to_str().unwrap()).unwrap();
5211
5212        // Create
5213        let mut file: *mut super::TgmFile = ptr::null_mut();
5214        let err = super::tgm_file_create(c_path.as_ptr(), &mut file);
5215        assert!(matches!(err, super::TgmError::Ok));
5216
5217        // Encode a message in memory, then append raw bytes
5218        let encoded = ffi_encode_single_f32_tensor(&[1.0f32, 2.0], "");
5219        let err = super::tgm_file_append_raw(file, encoded.as_ptr(), encoded.len());
5220        assert!(matches!(err, super::TgmError::Ok));
5221
5222        // Count
5223        let mut count: usize = 0;
5224        let err = super::tgm_file_message_count(file, &mut count);
5225        assert!(matches!(err, super::TgmError::Ok));
5226        assert_eq!(count, 1);
5227
5228        super::tgm_file_close(file);
5229        let _ = std::fs::remove_file(&path);
5230    }
5231
5232    // ── tgm_streaming_encoder_* ──
5233
5234    #[test]
5235    fn ffi_streaming_encoder_round_trip() {
5236        let dir = std::env::temp_dir();
5237        let path = dir.join("ffi_streaming_test.tgm");
5238        let _ = std::fs::remove_file(&path);
5239
5240        let c_path = CString::new(path.to_str().unwrap()).unwrap();
5241        let meta_json = CString::new(r#"{"version":3}"#).unwrap();
5242
5243        // Create
5244        let mut enc: *mut super::TgmStreamingEncoder = ptr::null_mut();
5245        let err = super::tgm_streaming_encoder_create(
5246            c_path.as_ptr(),
5247            meta_json.as_ptr(),
5248            ptr::null(), // no hash
5249            0,           // threads
5250            &mut enc,
5251        );
5252        assert!(matches!(err, super::TgmError::Ok));
5253        assert!(!enc.is_null());
5254
5255        // Write an object
5256        let values = [100.0f32, 200.0, 300.0];
5257        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
5258        let desc_json = CString::new(format!(
5259            r#"{{"type":"ntensor","ndim":1,"shape":[{len}],"strides":[1],"dtype":"float32","byte_order":"{bo}","encoding":"none","filter":"none","compression":"none"}}"#,
5260            len = values.len(),
5261            bo = if cfg!(target_endian = "little") { "little" } else { "big" },
5262        )).unwrap();
5263
5264        let err =
5265            super::tgm_streaming_encoder_write(enc, desc_json.as_ptr(), data.as_ptr(), data.len());
5266        assert!(matches!(err, super::TgmError::Ok));
5267
5268        // Count
5269        assert_eq!(super::tgm_streaming_encoder_count(enc), 1);
5270
5271        // Finish
5272        let err = super::tgm_streaming_encoder_finish(enc);
5273        assert!(matches!(err, super::TgmError::Ok));
5274
5275        // Double finish should fail
5276        let err = super::tgm_streaming_encoder_finish(enc);
5277        assert!(matches!(err, super::TgmError::InvalidArg));
5278
5279        // Count after finish
5280        assert_eq!(super::tgm_streaming_encoder_count(enc), 0);
5281
5282        // Free
5283        super::tgm_streaming_encoder_free(enc);
5284
5285        // Read back and verify
5286        let mut file: *mut super::TgmFile = ptr::null_mut();
5287        let err = super::tgm_file_open(c_path.as_ptr(), &mut file);
5288        assert!(matches!(err, super::TgmError::Ok));
5289
5290        let mut count: usize = 0;
5291        let err = super::tgm_file_message_count(file, &mut count);
5292        assert!(matches!(err, super::TgmError::Ok));
5293        assert_eq!(count, 1);
5294
5295        let mut msg: *mut super::TgmMessage = ptr::null_mut();
5296        let err = super::tgm_file_decode_message(file, 0, 0, 0, 0, &mut msg);
5297        assert!(matches!(err, super::TgmError::Ok));
5298
5299        let mut data_len: usize = 0;
5300        let dp = super::tgm_object_data(msg, 0, &mut data_len);
5301        let decoded_bytes = unsafe { slice::from_raw_parts(dp, data_len) };
5302        let decoded_values: Vec<f32> = decoded_bytes
5303            .chunks_exact(4)
5304            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
5305            .collect();
5306        assert_eq!(decoded_values, values);
5307
5308        super::tgm_message_free(msg);
5309        super::tgm_file_close(file);
5310        let _ = std::fs::remove_file(&path);
5311    }
5312
5313    #[test]
5314    fn ffi_streaming_encoder_null_args() {
5315        let mut enc: *mut super::TgmStreamingEncoder = ptr::null_mut();
5316
5317        // create with null path
5318        let meta = CString::new(r#"{"version":3}"#).unwrap();
5319        let err = super::tgm_streaming_encoder_create(
5320            ptr::null(),
5321            meta.as_ptr(),
5322            ptr::null(),
5323            0,
5324            &mut enc,
5325        );
5326        assert!(matches!(err, super::TgmError::InvalidArg));
5327
5328        // create with null metadata
5329        let p = CString::new("/tmp/dummy.tgm").unwrap();
5330        let err =
5331            super::tgm_streaming_encoder_create(p.as_ptr(), ptr::null(), ptr::null(), 0, &mut enc);
5332        assert!(matches!(err, super::TgmError::InvalidArg));
5333
5334        // create with null out
5335        let err = super::tgm_streaming_encoder_create(
5336            p.as_ptr(),
5337            meta.as_ptr(),
5338            ptr::null(),
5339            0,
5340            ptr::null_mut(),
5341        );
5342        assert!(matches!(err, super::TgmError::InvalidArg));
5343
5344        // write null enc
5345        let desc = CString::new(r#"{}"#).unwrap();
5346        let data = [0u8; 4];
5347        let err = super::tgm_streaming_encoder_write(
5348            ptr::null_mut(),
5349            desc.as_ptr(),
5350            data.as_ptr(),
5351            data.len(),
5352        );
5353        assert!(matches!(err, super::TgmError::InvalidArg));
5354
5355        // write null descriptor
5356        // Need a valid encoder for this — skip as it requires file creation
5357
5358        // finish null
5359        let err = super::tgm_streaming_encoder_finish(ptr::null_mut());
5360        assert!(matches!(err, super::TgmError::InvalidArg));
5361
5362        // count null
5363        assert_eq!(super::tgm_streaming_encoder_count(ptr::null()), 0);
5364
5365        // free null — should not crash
5366        super::tgm_streaming_encoder_free(ptr::null_mut());
5367    }
5368
5369    #[test]
5370    fn ffi_streaming_encoder_write_null_data() {
5371        let dir = std::env::temp_dir();
5372        let path = dir.join("ffi_streaming_null_data.tgm");
5373        let _ = std::fs::remove_file(&path);
5374
5375        let c_path = CString::new(path.to_str().unwrap()).unwrap();
5376        let meta_json = CString::new(r#"{"version":3}"#).unwrap();
5377
5378        let mut enc: *mut super::TgmStreamingEncoder = ptr::null_mut();
5379        let err = super::tgm_streaming_encoder_create(
5380            c_path.as_ptr(),
5381            meta_json.as_ptr(),
5382            ptr::null(),
5383            0,
5384            &mut enc,
5385        );
5386        assert!(matches!(err, super::TgmError::Ok));
5387
5388        let desc = CString::new(r#"{"type":"ntensor","ndim":1,"shape":[1],"strides":[1],"dtype":"float32","byte_order":"little","encoding":"none","filter":"none","compression":"none"}"#).unwrap();
5389        let err = super::tgm_streaming_encoder_write(enc, desc.as_ptr(), ptr::null(), 4);
5390        assert!(matches!(err, super::TgmError::InvalidArg));
5391
5392        // Write with null descriptor json
5393        let data = [0u8; 4];
5394        let err = super::tgm_streaming_encoder_write(enc, ptr::null(), data.as_ptr(), data.len());
5395        assert!(matches!(err, super::TgmError::InvalidArg));
5396
5397        super::tgm_streaming_encoder_free(enc);
5398        let _ = std::fs::remove_file(&path);
5399    }
5400
5401    #[test]
5402    fn ffi_streaming_encoder_with_preceder() {
5403        let dir = std::env::temp_dir();
5404        let path = dir.join("ffi_streaming_preceder.tgm");
5405        let _ = std::fs::remove_file(&path);
5406
5407        let c_path = CString::new(path.to_str().unwrap()).unwrap();
5408        let meta_json = CString::new(r#"{"version":3}"#).unwrap();
5409
5410        let mut enc: *mut super::TgmStreamingEncoder = ptr::null_mut();
5411        let err = super::tgm_streaming_encoder_create(
5412            c_path.as_ptr(),
5413            meta_json.as_ptr(),
5414            ptr::null(),
5415            0,
5416            &mut enc,
5417        );
5418        assert!(matches!(err, super::TgmError::Ok));
5419
5420        // Write preceder
5421        let preceder_json = CString::new(r#"{"param":"2t","source":"test"}"#).unwrap();
5422        let err = super::tgm_streaming_encoder_write_preceder(enc, preceder_json.as_ptr());
5423        assert!(matches!(err, super::TgmError::Ok));
5424
5425        // Write object after preceder
5426        let values = [42.0f32];
5427        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
5428        let desc_json = CString::new(format!(
5429            r#"{{"type":"ntensor","ndim":1,"shape":[1],"strides":[1],"dtype":"float32","byte_order":"{bo}","encoding":"none","filter":"none","compression":"none"}}"#,
5430            bo = if cfg!(target_endian = "little") { "little" } else { "big" },
5431        )).unwrap();
5432
5433        let err =
5434            super::tgm_streaming_encoder_write(enc, desc_json.as_ptr(), data.as_ptr(), data.len());
5435        assert!(matches!(err, super::TgmError::Ok));
5436
5437        let err = super::tgm_streaming_encoder_finish(enc);
5438        assert!(matches!(err, super::TgmError::Ok));
5439        super::tgm_streaming_encoder_free(enc);
5440
5441        // Re-open and verify the metadata contains the preceder keys
5442        let mut file: *mut super::TgmFile = ptr::null_mut();
5443        let err = super::tgm_file_open(c_path.as_ptr(), &mut file);
5444        assert!(matches!(err, super::TgmError::Ok));
5445
5446        let mut msg: *mut super::TgmMessage = ptr::null_mut();
5447        let err = super::tgm_file_decode_message(file, 0, 0, 0, 0, &mut msg);
5448        assert!(matches!(err, super::TgmError::Ok));
5449
5450        let mut meta: *mut super::TgmMetadata = ptr::null_mut();
5451        let err = super::tgm_message_metadata(msg, &mut meta);
5452        assert!(matches!(err, super::TgmError::Ok));
5453
5454        let key = CString::new("param").unwrap();
5455        let val_ptr = super::tgm_metadata_get_string(meta, key.as_ptr());
5456        assert!(!val_ptr.is_null());
5457        let val_str = unsafe { CStr::from_ptr(val_ptr) }.to_str().unwrap();
5458        assert_eq!(val_str, "2t");
5459
5460        super::tgm_metadata_free(meta);
5461        super::tgm_message_free(msg);
5462        super::tgm_file_close(file);
5463        let _ = std::fs::remove_file(&path);
5464    }
5465
5466    #[test]
5467    fn ffi_streaming_encoder_write_preceder_null_args() {
5468        let err = super::tgm_streaming_encoder_write_preceder(ptr::null_mut(), ptr::null());
5469        assert!(matches!(err, super::TgmError::InvalidArg));
5470    }
5471
5472    #[test]
5473    fn ffi_streaming_encoder_write_pre_encoded_null_args() {
5474        let err = super::tgm_streaming_encoder_write_pre_encoded(
5475            ptr::null_mut(),
5476            ptr::null(),
5477            ptr::null(),
5478            0,
5479        );
5480        assert!(matches!(err, super::TgmError::InvalidArg));
5481    }
5482
5483    // ── tgm_compute_hash ──
5484
5485    #[test]
5486    fn ffi_compute_hash_xxh3() {
5487        let data = b"hello world";
5488        let mut out = super::TgmBytes {
5489            data: ptr::null_mut(),
5490            len: 0,
5491        };
5492        let err = super::tgm_compute_hash(
5493            data.as_ptr(),
5494            data.len(),
5495            ptr::null(), // default = xxh3
5496            &mut out,
5497        );
5498        assert!(matches!(err, super::TgmError::Ok));
5499        assert!(!out.data.is_null());
5500        assert!(out.len > 0);
5501
5502        let hex = unsafe { std::str::from_utf8(slice::from_raw_parts(out.data, out.len)).unwrap() };
5503        // xxh3 produces a hex string
5504        assert!(!hex.is_empty());
5505        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
5506
5507        super::tgm_bytes_free(out);
5508    }
5509
5510    #[test]
5511    fn ffi_compute_hash_explicit_xxh3() {
5512        let data = b"test data";
5513        let algo = CString::new("xxh3").unwrap();
5514        let mut out = super::TgmBytes {
5515            data: ptr::null_mut(),
5516            len: 0,
5517        };
5518        let err = super::tgm_compute_hash(data.as_ptr(), data.len(), algo.as_ptr(), &mut out);
5519        assert!(matches!(err, super::TgmError::Ok));
5520        assert!(out.len > 0);
5521        super::tgm_bytes_free(out);
5522    }
5523
5524    #[test]
5525    fn ffi_compute_hash_null_data() {
5526        let mut out = super::TgmBytes {
5527            data: ptr::null_mut(),
5528            len: 0,
5529        };
5530        let err = super::tgm_compute_hash(ptr::null(), 0, ptr::null(), &mut out);
5531        assert!(matches!(err, super::TgmError::InvalidArg));
5532    }
5533
5534    #[test]
5535    fn ffi_compute_hash_null_out() {
5536        let data = b"hello";
5537        let err = super::tgm_compute_hash(data.as_ptr(), data.len(), ptr::null(), ptr::null_mut());
5538        assert!(matches!(err, super::TgmError::InvalidArg));
5539    }
5540
5541    #[test]
5542    fn ffi_compute_hash_invalid_algo() {
5543        let data = b"hello";
5544        let algo = CString::new("bogus_algo").unwrap();
5545        let mut out = super::TgmBytes {
5546            data: ptr::null_mut(),
5547            len: 0,
5548        };
5549        let err = super::tgm_compute_hash(data.as_ptr(), data.len(), algo.as_ptr(), &mut out);
5550        assert!(matches!(err, super::TgmError::InvalidArg));
5551    }
5552
5553    // ── tgm_simple_packing_compute_params ──
5554
5555    #[test]
5556    fn ffi_simple_packing_compute_params() {
5557        let values = [100.0f64, 200.0, 300.0, 400.0];
5558        let mut ref_val: f64 = 0.0;
5559        let mut bin_scale: i32 = 0;
5560        let err = super::tgm_simple_packing_compute_params(
5561            values.as_ptr(),
5562            values.len(),
5563            16,
5564            0,
5565            &mut ref_val,
5566            &mut bin_scale,
5567        );
5568        assert!(matches!(err, super::TgmError::Ok));
5569        // Reference value should be <= min(values)
5570        assert!(ref_val <= 100.0);
5571    }
5572
5573    #[test]
5574    fn ffi_simple_packing_compute_params_null_args() {
5575        let mut ref_val: f64 = 0.0;
5576        let mut bin_scale: i32 = 0;
5577
5578        // null values
5579        let err = super::tgm_simple_packing_compute_params(
5580            ptr::null(),
5581            0,
5582            16,
5583            0,
5584            &mut ref_val,
5585            &mut bin_scale,
5586        );
5587        assert!(matches!(err, super::TgmError::InvalidArg));
5588
5589        // null out_reference_value
5590        let values = [1.0f64];
5591        let err = super::tgm_simple_packing_compute_params(
5592            values.as_ptr(),
5593            values.len(),
5594            16,
5595            0,
5596            ptr::null_mut(),
5597            &mut bin_scale,
5598        );
5599        assert!(matches!(err, super::TgmError::InvalidArg));
5600
5601        // null out_binary_scale_factor
5602        let err = super::tgm_simple_packing_compute_params(
5603            values.as_ptr(),
5604            values.len(),
5605            16,
5606            0,
5607            &mut ref_val,
5608            ptr::null_mut(),
5609        );
5610        assert!(matches!(err, super::TgmError::InvalidArg));
5611    }
5612
5613    // ── tgm_last_error ──
5614
5615    #[test]
5616    fn ffi_last_error_after_success() {
5617        // After a successful encode, last_error should remain from whatever was
5618        // set before (or NULL). We don't clear on success.
5619        let values = [1.0f32];
5620        let _ = ffi_encode_single_f32_tensor(&values, "");
5621        // No crash, test passes
5622    }
5623
5624    #[test]
5625    fn ffi_last_error_after_failure() {
5626        // Trigger an error
5627        let mut out = super::TgmBytes {
5628            data: ptr::null_mut(),
5629            len: 0,
5630        };
5631        let _ = super::tgm_encode(
5632            ptr::null(),
5633            ptr::null(),
5634            ptr::null(),
5635            0,
5636            ptr::null(),
5637            0,
5638            &mut out,
5639        );
5640
5641        let err_ptr = super::tgm_last_error();
5642        assert!(!err_ptr.is_null());
5643        let err_str = unsafe { CStr::from_ptr(err_ptr) }.to_str().unwrap();
5644        assert!(err_str.contains("null"));
5645    }
5646
5647    // ── tgm_error_string ──
5648
5649    #[test]
5650    fn ffi_error_string_all_variants() {
5651        let check = |err: super::TgmError, expected: &str| {
5652            let ptr = super::tgm_error_string(err);
5653            assert!(!ptr.is_null());
5654            let s = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap();
5655            assert_eq!(s, expected);
5656        };
5657
5658        check(super::TgmError::Ok, "ok");
5659        check(super::TgmError::Framing, "framing error");
5660        check(super::TgmError::Metadata, "metadata error");
5661        check(super::TgmError::Encoding, "encoding error");
5662        check(super::TgmError::Compression, "compression error");
5663        check(super::TgmError::Object, "object error");
5664        check(super::TgmError::Io, "I/O error");
5665        check(super::TgmError::HashMismatch, "hash mismatch");
5666        check(super::TgmError::InvalidArg, "invalid argument");
5667        check(super::TgmError::EndOfIter, "end of iteration");
5668        check(super::TgmError::Remote, "remote error");
5669    }
5670
5671    // ── tgm_bytes_free safety ──
5672
5673    #[test]
5674    fn ffi_bytes_free_null_data() {
5675        let buf = super::TgmBytes {
5676            data: ptr::null_mut(),
5677            len: 0,
5678        };
5679        super::tgm_bytes_free(buf); // should not crash
5680    }
5681
5682    // ── tgm_message_free / tgm_metadata_free / tgm_scan_free null safety ──
5683
5684    #[test]
5685    fn ffi_free_null_handles() {
5686        super::tgm_message_free(ptr::null_mut());
5687        super::tgm_metadata_free(ptr::null_mut());
5688        super::tgm_scan_free(ptr::null_mut());
5689        super::tgm_file_close(ptr::null_mut());
5690        super::tgm_streaming_encoder_free(ptr::null_mut());
5691        // Should all be no-ops, no crash
5692    }
5693
5694    // ── tgm_encode_pre_encoded ──
5695
5696    #[test]
5697    fn ffi_encode_pre_encoded_null_args() {
5698        let mut out = super::TgmBytes {
5699            data: ptr::null_mut(),
5700            len: 0,
5701        };
5702        let err = super::tgm_encode_pre_encoded(
5703            ptr::null(),
5704            ptr::null(),
5705            ptr::null(),
5706            0,
5707            ptr::null(),
5708            0,
5709            &mut out,
5710        );
5711        assert!(matches!(err, super::TgmError::InvalidArg));
5712
5713        let json = CString::new(r#"{"version":3,"descriptors":[]}"#).unwrap();
5714        let err = super::tgm_encode_pre_encoded(
5715            json.as_ptr(),
5716            ptr::null(),
5717            ptr::null(),
5718            0,
5719            ptr::null(),
5720            0,
5721            ptr::null_mut(),
5722        );
5723        assert!(matches!(err, super::TgmError::InvalidArg));
5724    }
5725
5726    #[test]
5727    fn ffi_encode_pre_encoded_round_trip() {
5728        // Encode with pre_encoded: for "none" encoding, the raw bytes are the payload
5729        let values = [5.0f32, 6.0, 7.0];
5730        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
5731
5732        let json = format!(
5733            r#"{{"version":3,"descriptors":[{{"type":"ntensor","ndim":1,"shape":[{len}],"strides":[1],"dtype":"float32","byte_order":"{bo}","encoding":"none","filter":"none","compression":"none"}}]}}"#,
5734            len = values.len(),
5735            bo = if cfg!(target_endian = "little") {
5736                "little"
5737            } else {
5738                "big"
5739            },
5740        );
5741        let c_json = CString::new(json).unwrap();
5742        let data_ptr: *const u8 = data.as_ptr();
5743        let data_len: usize = data.len();
5744
5745        let mut out = super::TgmBytes {
5746            data: ptr::null_mut(),
5747            len: 0,
5748        };
5749        let err = super::tgm_encode_pre_encoded(
5750            c_json.as_ptr(),
5751            &data_ptr as *const *const u8,
5752            &data_len as *const usize,
5753            1,
5754            ptr::null(),
5755            0,
5756            &mut out,
5757        );
5758        assert!(matches!(err, super::TgmError::Ok));
5759
5760        let encoded = unsafe { slice::from_raw_parts(out.data, out.len) }.to_vec();
5761        super::tgm_bytes_free(out);
5762
5763        // Decode
5764        let mut msg: *mut super::TgmMessage = ptr::null_mut();
5765        let err = super::tgm_decode(encoded.as_ptr(), encoded.len(), 0, 0, 0, &mut msg);
5766        assert!(matches!(err, super::TgmError::Ok));
5767
5768        let mut dl: usize = 0;
5769        let dp = super::tgm_object_data(msg, 0, &mut dl);
5770        let decoded_bytes = unsafe { slice::from_raw_parts(dp, dl) };
5771        let decoded_values: Vec<f32> = decoded_bytes
5772            .chunks_exact(4)
5773            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
5774            .collect();
5775        assert_eq!(decoded_values, values);
5776
5777        super::tgm_message_free(msg);
5778    }
5779
5780    // ── tgm_buffer_iter_* ──
5781
5782    #[test]
5783    fn ffi_buffer_iter_round_trip() {
5784        let msg1 = ffi_encode_single_f32_tensor(&[1.0f32], "");
5785        let msg2 = ffi_encode_single_f32_tensor(&[2.0f32], "");
5786        let mut concat = msg1.clone();
5787        concat.extend_from_slice(&msg2);
5788
5789        let mut iter: *mut super::TgmBufferIter = ptr::null_mut();
5790        let err = super::tgm_buffer_iter_create(concat.as_ptr(), concat.len(), &mut iter);
5791        assert!(matches!(err, super::TgmError::Ok));
5792        assert!(!iter.is_null());
5793
5794        assert_eq!(super::tgm_buffer_iter_count(iter), 2);
5795
5796        // First message
5797        let mut out_buf: *const u8 = ptr::null();
5798        let mut out_len: usize = 0;
5799        let err = super::tgm_buffer_iter_next(iter, &mut out_buf, &mut out_len);
5800        assert!(matches!(err, super::TgmError::Ok));
5801        assert!(!out_buf.is_null());
5802        assert_eq!(out_len, msg1.len());
5803
5804        // Second message
5805        let err = super::tgm_buffer_iter_next(iter, &mut out_buf, &mut out_len);
5806        assert!(matches!(err, super::TgmError::Ok));
5807        assert_eq!(out_len, msg2.len());
5808
5809        // End of iteration
5810        let err = super::tgm_buffer_iter_next(iter, &mut out_buf, &mut out_len);
5811        assert!(matches!(err, super::TgmError::EndOfIter));
5812
5813        super::tgm_buffer_iter_free(iter);
5814    }
5815
5816    #[test]
5817    fn ffi_buffer_iter_null_args() {
5818        let mut iter: *mut super::TgmBufferIter = ptr::null_mut();
5819        let err = super::tgm_buffer_iter_create(ptr::null(), 0, &mut iter);
5820        assert!(matches!(err, super::TgmError::InvalidArg));
5821
5822        let data = [0u8; 10];
5823        let err = super::tgm_buffer_iter_create(data.as_ptr(), data.len(), ptr::null_mut());
5824        assert!(matches!(err, super::TgmError::InvalidArg));
5825
5826        // next with null iter
5827        let mut out_buf: *const u8 = ptr::null();
5828        let mut out_len: usize = 0;
5829        let err = super::tgm_buffer_iter_next(ptr::null_mut(), &mut out_buf, &mut out_len);
5830        assert!(matches!(err, super::TgmError::InvalidArg));
5831
5832        // next with null out_buf
5833        // We need a valid iter for this, but let's test the easy null cases
5834        let err = super::tgm_buffer_iter_next(ptr::null_mut(), ptr::null_mut(), &mut out_len);
5835        assert!(matches!(err, super::TgmError::InvalidArg));
5836
5837        // count null
5838        assert_eq!(super::tgm_buffer_iter_count(ptr::null()), 0);
5839
5840        // free null — no crash
5841        super::tgm_buffer_iter_free(ptr::null_mut());
5842    }
5843
5844    // ── tgm_object_iter_* ──
5845
5846    #[test]
5847    fn ffi_object_iter_round_trip() {
5848        let encoded = ffi_encode_single_f32_tensor(&[10.0f32, 20.0], "");
5849
5850        let mut iter: *mut super::TgmObjectIter = ptr::null_mut();
5851        let err = super::tgm_object_iter_create(
5852            encoded.as_ptr(),
5853            encoded.len(),
5854            0, // no native byte order
5855            0, // verify_hash
5856            &mut iter,
5857        );
5858        assert!(matches!(err, super::TgmError::Ok));
5859        assert!(!iter.is_null());
5860
5861        // Get the single object
5862        let mut msg: *mut super::TgmMessage = ptr::null_mut();
5863        let err = super::tgm_object_iter_next(iter, &mut msg);
5864        assert!(matches!(err, super::TgmError::Ok));
5865        assert!(!msg.is_null());
5866
5867        assert_eq!(super::tgm_message_num_objects(msg), 1);
5868        assert_eq!(super::tgm_message_version(msg), 3);
5869
5870        let mut data_len: usize = 0;
5871        let dp = super::tgm_object_data(msg, 0, &mut data_len);
5872        assert!(!dp.is_null());
5873        assert_eq!(data_len, 8); // 2 × f32
5874
5875        super::tgm_message_free(msg);
5876
5877        // Iteration should be exhausted
5878        let mut msg2: *mut super::TgmMessage = ptr::null_mut();
5879        let err = super::tgm_object_iter_next(iter, &mut msg2);
5880        assert!(matches!(err, super::TgmError::EndOfIter));
5881
5882        super::tgm_object_iter_free(iter);
5883    }
5884
5885    #[test]
5886    fn ffi_object_iter_null_args() {
5887        let mut iter: *mut super::TgmObjectIter = ptr::null_mut();
5888        let err = super::tgm_object_iter_create(ptr::null(), 0, 0, 0, &mut iter);
5889        assert!(matches!(err, super::TgmError::InvalidArg));
5890
5891        let data = [0u8; 10];
5892        let err = super::tgm_object_iter_create(data.as_ptr(), data.len(), 0, 0, ptr::null_mut());
5893        assert!(matches!(err, super::TgmError::InvalidArg));
5894
5895        // next null iter
5896        let mut msg: *mut super::TgmMessage = ptr::null_mut();
5897        let err = super::tgm_object_iter_next(ptr::null_mut(), &mut msg);
5898        assert!(matches!(err, super::TgmError::InvalidArg));
5899
5900        // next null out
5901        let err = super::tgm_object_iter_next(ptr::null_mut(), ptr::null_mut());
5902        assert!(matches!(err, super::TgmError::InvalidArg));
5903
5904        // free null — no crash
5905        super::tgm_object_iter_free(ptr::null_mut());
5906    }
5907
5908    // ── tgm_file_iter_* ──
5909
5910    #[test]
5911    fn ffi_file_iter_round_trip() {
5912        let dir = std::env::temp_dir();
5913        let path = dir.join("ffi_file_iter_test.tgm");
5914        let _ = std::fs::remove_file(&path);
5915
5916        let c_path = CString::new(path.to_str().unwrap()).unwrap();
5917
5918        // Create file with 2 messages
5919        let mut file: *mut super::TgmFile = ptr::null_mut();
5920        let err = super::tgm_file_create(c_path.as_ptr(), &mut file);
5921        assert!(matches!(err, super::TgmError::Ok));
5922
5923        let msg1 = ffi_encode_single_f32_tensor(&[1.0f32], "");
5924        let msg2 = ffi_encode_single_f32_tensor(&[2.0f32], "");
5925        let err = super::tgm_file_append_raw(file, msg1.as_ptr(), msg1.len());
5926        assert!(matches!(err, super::TgmError::Ok));
5927        let err = super::tgm_file_append_raw(file, msg2.as_ptr(), msg2.len());
5928        assert!(matches!(err, super::TgmError::Ok));
5929
5930        // Create iterator
5931        let mut iter: *mut super::TgmFileIter = ptr::null_mut();
5932        let err = super::tgm_file_iter_create(file, &mut iter);
5933        assert!(matches!(err, super::TgmError::Ok));
5934        assert!(!iter.is_null());
5935
5936        // First message
5937        let mut out = super::TgmBytes {
5938            data: ptr::null_mut(),
5939            len: 0,
5940        };
5941        let err = super::tgm_file_iter_next(iter, &mut out);
5942        assert!(matches!(err, super::TgmError::Ok));
5943        assert!(!out.data.is_null());
5944        assert_eq!(out.len, msg1.len());
5945        super::tgm_bytes_free(out);
5946
5947        // Second message
5948        let mut out2 = super::TgmBytes {
5949            data: ptr::null_mut(),
5950            len: 0,
5951        };
5952        let err = super::tgm_file_iter_next(iter, &mut out2);
5953        assert!(matches!(err, super::TgmError::Ok));
5954        super::tgm_bytes_free(out2);
5955
5956        // End
5957        let mut out3 = super::TgmBytes {
5958            data: ptr::null_mut(),
5959            len: 0,
5960        };
5961        let err = super::tgm_file_iter_next(iter, &mut out3);
5962        assert!(matches!(err, super::TgmError::EndOfIter));
5963
5964        super::tgm_file_iter_free(iter);
5965        super::tgm_file_close(file);
5966        let _ = std::fs::remove_file(&path);
5967    }
5968
5969    #[test]
5970    fn ffi_file_iter_null_args() {
5971        let mut iter: *mut super::TgmFileIter = ptr::null_mut();
5972        let err = super::tgm_file_iter_create(ptr::null_mut(), &mut iter);
5973        assert!(matches!(err, super::TgmError::InvalidArg));
5974
5975        let err = super::tgm_file_iter_create(ptr::null_mut(), ptr::null_mut());
5976        assert!(matches!(err, super::TgmError::InvalidArg));
5977
5978        // next null
5979        let mut out = super::TgmBytes {
5980            data: ptr::null_mut(),
5981            len: 0,
5982        };
5983        let err = super::tgm_file_iter_next(ptr::null_mut(), &mut out);
5984        assert!(matches!(err, super::TgmError::InvalidArg));
5985
5986        let err = super::tgm_file_iter_next(ptr::null_mut(), ptr::null_mut());
5987        assert!(matches!(err, super::TgmError::InvalidArg));
5988
5989        // free null — no crash
5990        super::tgm_file_iter_free(ptr::null_mut());
5991    }
5992
5993    // ── tgm_encode zero objects (metadata-only message) ──
5994
5995    #[test]
5996    fn ffi_encode_decode_zero_objects() {
5997        let json = CString::new(r#"{"version":3,"descriptors":[],"source":"empty"}"#).unwrap();
5998        let mut out = super::TgmBytes {
5999            data: ptr::null_mut(),
6000            len: 0,
6001        };
6002        let err = super::tgm_encode(
6003            json.as_ptr(),
6004            ptr::null(),
6005            ptr::null(),
6006            0,
6007            ptr::null(),
6008            0,
6009            &mut out,
6010        );
6011        assert!(matches!(err, super::TgmError::Ok));
6012
6013        let encoded = unsafe { slice::from_raw_parts(out.data, out.len) }.to_vec();
6014        super::tgm_bytes_free(out);
6015
6016        let mut msg: *mut super::TgmMessage = ptr::null_mut();
6017        let err = super::tgm_decode(encoded.as_ptr(), encoded.len(), 0, 0, 0, &mut msg);
6018        assert!(matches!(err, super::TgmError::Ok));
6019
6020        assert_eq!(super::tgm_message_version(msg), 3);
6021        assert_eq!(super::tgm_message_num_objects(msg), 0);
6022
6023        super::tgm_message_free(msg);
6024    }
6025
6026    // ── tgm_streaming_encoder with hash ──
6027
6028    /// End-to-end: streaming-encode a hashed message, read the file
6029    /// back as bytes, decode via the buffer-based `tgm_decode`, and
6030    /// confirm the inline-hash slot is surfaced through the
6031    /// `tgm_payload_has_hash` / `tgm_object_hash_*` accessors.
6032    ///
6033    /// As of pass 5 the file-based decode path
6034    /// (`tgm_file_decode_message`) also surfaces inline hashes
6035    /// via a re-read of the raw message bytes — see
6036    /// `ffi_file_decode_surfaces_inline_hash`.
6037    #[test]
6038    fn ffi_streaming_encoder_with_hash() {
6039        let dir = std::env::temp_dir();
6040        let path = dir.join("ffi_streaming_hash.tgm");
6041        let _ = std::fs::remove_file(&path);
6042
6043        let c_path = CString::new(path.to_str().unwrap()).unwrap();
6044        let meta_json = CString::new(r#"{"version":3}"#).unwrap();
6045        let hash_algo = CString::new("xxh3").unwrap();
6046
6047        let mut enc: *mut super::TgmStreamingEncoder = ptr::null_mut();
6048        let err = super::tgm_streaming_encoder_create(
6049            c_path.as_ptr(),
6050            meta_json.as_ptr(),
6051            hash_algo.as_ptr(),
6052            0,
6053            &mut enc,
6054        );
6055        assert!(matches!(err, super::TgmError::Ok));
6056
6057        let values = [1.0f32, 2.0];
6058        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
6059        let desc_json = CString::new(format!(
6060            r#"{{"type":"ntensor","ndim":1,"shape":[{len}],"strides":[1],"dtype":"float32","byte_order":"{bo}","encoding":"none","filter":"none","compression":"none"}}"#,
6061            len = values.len(),
6062            bo = if cfg!(target_endian = "little") { "little" } else { "big" },
6063        )).unwrap();
6064
6065        let err =
6066            super::tgm_streaming_encoder_write(enc, desc_json.as_ptr(), data.as_ptr(), data.len());
6067        assert!(matches!(err, super::TgmError::Ok));
6068
6069        let err = super::tgm_streaming_encoder_finish(enc);
6070        assert!(matches!(err, super::TgmError::Ok));
6071        super::tgm_streaming_encoder_free(enc);
6072
6073        // Read back via the buffer-based decode path so inline
6074        // hashes are extracted.
6075        let bytes = std::fs::read(&path).expect("read file");
6076        let mut msg: *mut super::TgmMessage = ptr::null_mut();
6077        let err = super::tgm_decode(bytes.as_ptr(), bytes.len(), 0, 0, 0, &mut msg);
6078        assert!(matches!(err, super::TgmError::Ok));
6079
6080        assert_eq!(super::tgm_payload_has_hash(msg, 0), 1);
6081        let ht = unsafe { CStr::from_ptr(super::tgm_object_hash_type(msg, 0)) }
6082            .to_str()
6083            .unwrap();
6084        assert_eq!(ht, "xxh3");
6085        let hv = unsafe { CStr::from_ptr(super::tgm_object_hash_value(msg, 0)) }
6086            .to_str()
6087            .unwrap();
6088        assert_eq!(hv.len(), 16, "xxh3 digest is 16 hex chars");
6089
6090        super::tgm_message_free(msg);
6091        let _ = std::fs::remove_file(&path);
6092    }
6093
6094    /// Pass-5 cross-language parity: the file-based decode path
6095    /// (`tgm_file_decode_message`) surfaces the same inline hash
6096    /// as the buffer-based path (`tgm_decode`) for the same
6097    /// underlying bytes.  Pins the symmetry added by the
6098    /// `read_message` + `data_object_inline_hashes` two-step
6099    /// inside the FFI file decoder.
6100    #[test]
6101    fn ffi_file_decode_surfaces_inline_hash() {
6102        // Encode a hashed message into a temp file via the streaming
6103        // encoder, then open it with tgm_file_open + decode via
6104        // tgm_file_decode_message and confirm the hash accessors
6105        // report the same digest that tgm_decode surfaces.
6106        let dir = std::env::temp_dir();
6107        let path = dir.join("ffi_file_hash.tgm");
6108        let _ = std::fs::remove_file(&path);
6109
6110        let c_path = CString::new(path.to_str().unwrap()).unwrap();
6111        let meta_json = CString::new(r#"{"version":3}"#).unwrap();
6112        let hash_algo = CString::new("xxh3").unwrap();
6113        let mut enc: *mut super::TgmStreamingEncoder = ptr::null_mut();
6114        assert!(matches!(
6115            super::tgm_streaming_encoder_create(
6116                c_path.as_ptr(),
6117                meta_json.as_ptr(),
6118                hash_algo.as_ptr(),
6119                0,
6120                &mut enc,
6121            ),
6122            super::TgmError::Ok
6123        ));
6124        let values = [1.0f32, 2.0, 3.0];
6125        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
6126        let desc_json = CString::new(format!(
6127            r#"{{"type":"ntensor","ndim":1,"shape":[{}],"strides":[1],"dtype":"float32","byte_order":"{}","encoding":"none","filter":"none","compression":"none"}}"#,
6128            values.len(),
6129            if cfg!(target_endian = "little") { "little" } else { "big" },
6130        )).unwrap();
6131        assert!(matches!(
6132            super::tgm_streaming_encoder_write(enc, desc_json.as_ptr(), data.as_ptr(), data.len(),),
6133            super::TgmError::Ok
6134        ));
6135        assert!(matches!(
6136            super::tgm_streaming_encoder_finish(enc),
6137            super::TgmError::Ok
6138        ));
6139        super::tgm_streaming_encoder_free(enc);
6140
6141        // File path: tgm_file_open + tgm_file_decode_message.
6142        let mut file: *mut super::TgmFile = ptr::null_mut();
6143        assert!(matches!(
6144            super::tgm_file_open(c_path.as_ptr(), &mut file),
6145            super::TgmError::Ok
6146        ));
6147        let mut file_msg: *mut super::TgmMessage = ptr::null_mut();
6148        assert!(matches!(
6149            super::tgm_file_decode_message(file, 0, 0, 0, 0, &mut file_msg),
6150            super::TgmError::Ok
6151        ));
6152        let file_has = super::tgm_payload_has_hash(file_msg, 0);
6153        let file_hv_ptr = super::tgm_object_hash_value(file_msg, 0);
6154        assert_eq!(file_has, 1);
6155        assert!(!file_hv_ptr.is_null());
6156        let file_hv = unsafe { CStr::from_ptr(file_hv_ptr) }
6157            .to_str()
6158            .unwrap()
6159            .to_string();
6160
6161        // Buffer path: same file, read into memory, tgm_decode.
6162        let bytes = std::fs::read(&path).unwrap();
6163        let mut buf_msg: *mut super::TgmMessage = ptr::null_mut();
6164        assert!(matches!(
6165            super::tgm_decode(bytes.as_ptr(), bytes.len(), 0, 0, 0, &mut buf_msg),
6166            super::TgmError::Ok
6167        ));
6168        let buf_hv_ptr = super::tgm_object_hash_value(buf_msg, 0);
6169        let buf_hv = unsafe { CStr::from_ptr(buf_hv_ptr) }
6170            .to_str()
6171            .unwrap()
6172            .to_string();
6173
6174        assert_eq!(
6175            file_hv, buf_hv,
6176            "file-path and buffer-path hash values must agree"
6177        );
6178        assert_eq!(file_hv.len(), 16, "xxh3 digest is 16 hex chars");
6179
6180        super::tgm_message_free(file_msg);
6181        super::tgm_message_free(buf_msg);
6182        super::tgm_file_close(file);
6183        let _ = std::fs::remove_file(&path);
6184    }
6185
6186    // ── tgm_streaming_encoder_create with invalid hash algo ──
6187
6188    #[test]
6189    fn ffi_streaming_encoder_invalid_hash_algo() {
6190        let dir = std::env::temp_dir();
6191        let path = dir.join("ffi_streaming_bad_hash.tgm");
6192        let c_path = CString::new(path.to_str().unwrap()).unwrap();
6193        let meta_json = CString::new(r#"{"version":3}"#).unwrap();
6194        let bad_algo = CString::new("bogus_hash").unwrap();
6195
6196        let mut enc: *mut super::TgmStreamingEncoder = ptr::null_mut();
6197        let err = super::tgm_streaming_encoder_create(
6198            c_path.as_ptr(),
6199            meta_json.as_ptr(),
6200            bad_algo.as_ptr(),
6201            0,
6202            &mut enc,
6203        );
6204        assert!(matches!(err, super::TgmError::InvalidArg));
6205        let _ = std::fs::remove_file(&path);
6206    }
6207
6208    // ── tgm_streaming_encoder_create with invalid metadata JSON ──
6209
6210    #[test]
6211    fn ffi_streaming_encoder_invalid_metadata() {
6212        let dir = std::env::temp_dir();
6213        let path = dir.join("ffi_streaming_bad_meta.tgm");
6214        let c_path = CString::new(path.to_str().unwrap()).unwrap();
6215        let bad_meta = CString::new("not json").unwrap();
6216
6217        let mut enc: *mut super::TgmStreamingEncoder = ptr::null_mut();
6218        let err = super::tgm_streaming_encoder_create(
6219            c_path.as_ptr(),
6220            bad_meta.as_ptr(),
6221            ptr::null(),
6222            0,
6223            &mut enc,
6224        );
6225        assert!(matches!(err, super::TgmError::Metadata));
6226        let _ = std::fs::remove_file(&path);
6227    }
6228
6229    // ── Multiple objects encode/decode ──
6230
6231    #[test]
6232    fn ffi_encode_decode_multiple_objects() {
6233        let vals1 = [1.0f32, 2.0];
6234        let vals2 = [10.0f32, 20.0, 30.0];
6235        let bo = if cfg!(target_endian = "little") {
6236            "little"
6237        } else {
6238            "big"
6239        };
6240
6241        let json = format!(
6242            r#"{{"version":3,"descriptors":[{{"type":"ntensor","ndim":1,"shape":[{len1}],"strides":[1],"dtype":"float32","byte_order":"{bo}","encoding":"none","filter":"none","compression":"none"}},{{"type":"ntensor","ndim":1,"shape":[{len2}],"strides":[1],"dtype":"float32","byte_order":"{bo}","encoding":"none","filter":"none","compression":"none"}}]}}"#,
6243            len1 = vals1.len(),
6244            len2 = vals2.len(),
6245            bo = bo,
6246        );
6247
6248        let data1: Vec<u8> = vals1.iter().flat_map(|v| v.to_ne_bytes()).collect();
6249        let data2: Vec<u8> = vals2.iter().flat_map(|v| v.to_ne_bytes()).collect();
6250
6251        let c_json = CString::new(json).unwrap();
6252        let data_ptrs: [*const u8; 2] = [data1.as_ptr(), data2.as_ptr()];
6253        let data_lens: [usize; 2] = [data1.len(), data2.len()];
6254
6255        let mut out = super::TgmBytes {
6256            data: ptr::null_mut(),
6257            len: 0,
6258        };
6259        let err = super::tgm_encode(
6260            c_json.as_ptr(),
6261            data_ptrs.as_ptr(),
6262            data_lens.as_ptr(),
6263            2,
6264            ptr::null(),
6265            0,
6266            &mut out,
6267        );
6268        assert!(matches!(err, super::TgmError::Ok));
6269
6270        let encoded = unsafe { slice::from_raw_parts(out.data, out.len) }.to_vec();
6271        super::tgm_bytes_free(out);
6272
6273        // Decode all
6274        let mut msg: *mut super::TgmMessage = ptr::null_mut();
6275        let err = super::tgm_decode(encoded.as_ptr(), encoded.len(), 0, 0, 0, &mut msg);
6276        assert!(matches!(err, super::TgmError::Ok));
6277        assert_eq!(super::tgm_message_num_objects(msg), 2);
6278
6279        // Object 0
6280        let mut dl: usize = 0;
6281        let dp = super::tgm_object_data(msg, 0, &mut dl);
6282        let decoded0: Vec<f32> = unsafe { slice::from_raw_parts(dp, dl) }
6283            .chunks_exact(4)
6284            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
6285            .collect();
6286        assert_eq!(decoded0, vals1);
6287
6288        // Object 1
6289        let dp1 = super::tgm_object_data(msg, 1, &mut dl);
6290        let decoded1: Vec<f32> = unsafe { slice::from_raw_parts(dp1, dl) }
6291            .chunks_exact(4)
6292            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
6293            .collect();
6294        assert_eq!(decoded1, vals2);
6295
6296        // Shape check
6297        let shape0 = super::tgm_object_shape(msg, 0);
6298        assert_eq!(unsafe { *shape0 }, vals1.len() as u64);
6299        let shape1 = super::tgm_object_shape(msg, 1);
6300        assert_eq!(unsafe { *shape1 }, vals2.len() as u64);
6301
6302        super::tgm_message_free(msg);
6303    }
6304
6305    // ── tgm_encode with base metadata ──
6306
6307    #[test]
6308    fn ffi_encode_decode_with_base_metadata() {
6309        let bo = if cfg!(target_endian = "little") {
6310            "little"
6311        } else {
6312            "big"
6313        };
6314        let json = format!(
6315            r#"{{"version":3,"base":[{{"param":"2t","level":"surface"}}],"descriptors":[{{"type":"ntensor","ndim":1,"shape":[2],"strides":[1],"dtype":"float32","byte_order":"{bo}","encoding":"none","filter":"none","compression":"none"}}]}}"#,
6316            bo = bo,
6317        );
6318
6319        let data: Vec<u8> = [1.0f32, 2.0].iter().flat_map(|v| v.to_ne_bytes()).collect();
6320        let c_json = CString::new(json).unwrap();
6321        let data_ptr: *const u8 = data.as_ptr();
6322        let data_len: usize = data.len();
6323
6324        let mut out = super::TgmBytes {
6325            data: ptr::null_mut(),
6326            len: 0,
6327        };
6328        let err = super::tgm_encode(
6329            c_json.as_ptr(),
6330            &data_ptr as *const *const u8,
6331            &data_len as *const usize,
6332            1,
6333            ptr::null(),
6334            0,
6335            &mut out,
6336        );
6337        assert!(matches!(err, super::TgmError::Ok));
6338
6339        let encoded = unsafe { slice::from_raw_parts(out.data, out.len) }.to_vec();
6340        super::tgm_bytes_free(out);
6341
6342        // Decode metadata and check base keys
6343        let mut meta: *mut super::TgmMetadata = ptr::null_mut();
6344        let err = super::tgm_decode_metadata(encoded.as_ptr(), encoded.len(), &mut meta);
6345        assert!(matches!(err, super::TgmError::Ok));
6346
6347        let key = CString::new("param").unwrap();
6348        let val_ptr = super::tgm_metadata_get_string(meta, key.as_ptr());
6349        assert!(!val_ptr.is_null());
6350        let val_str = unsafe { CStr::from_ptr(val_ptr) }.to_str().unwrap();
6351        assert_eq!(val_str, "2t");
6352
6353        let key2 = CString::new("level").unwrap();
6354        let val_ptr2 = super::tgm_metadata_get_string(meta, key2.as_ptr());
6355        assert!(!val_ptr2.is_null());
6356        let val_str2 = unsafe { CStr::from_ptr(val_ptr2) }.to_str().unwrap();
6357        assert_eq!(val_str2, "surface");
6358
6359        super::tgm_metadata_free(meta);
6360    }
6361
6362    // ── tgm_compute_hash deterministic ──
6363
6364    #[test]
6365    fn ffi_compute_hash_deterministic() {
6366        let data = b"deterministic hash test";
6367        let mut out1 = super::TgmBytes {
6368            data: ptr::null_mut(),
6369            len: 0,
6370        };
6371        let mut out2 = super::TgmBytes {
6372            data: ptr::null_mut(),
6373            len: 0,
6374        };
6375
6376        let err = super::tgm_compute_hash(data.as_ptr(), data.len(), ptr::null(), &mut out1);
6377        assert!(matches!(err, super::TgmError::Ok));
6378
6379        let err = super::tgm_compute_hash(data.as_ptr(), data.len(), ptr::null(), &mut out2);
6380        assert!(matches!(err, super::TgmError::Ok));
6381
6382        let hex1 = unsafe { slice::from_raw_parts(out1.data, out1.len) };
6383        let hex2 = unsafe { slice::from_raw_parts(out2.data, out2.len) };
6384        assert_eq!(hex1, hex2);
6385
6386        super::tgm_bytes_free(out1);
6387        super::tgm_bytes_free(out2);
6388    }
6389
6390    // ── tgm_streaming_encoder_write_pre_encoded round-trip ──
6391
6392    #[test]
6393    fn ffi_streaming_encoder_write_pre_encoded_round_trip() {
6394        let dir = std::env::temp_dir();
6395        let path = dir.join("ffi_streaming_pre_encoded.tgm");
6396        let _ = std::fs::remove_file(&path);
6397
6398        let c_path = CString::new(path.to_str().unwrap()).unwrap();
6399        let meta_json = CString::new(r#"{"version":3}"#).unwrap();
6400
6401        let mut enc: *mut super::TgmStreamingEncoder = ptr::null_mut();
6402        let err = super::tgm_streaming_encoder_create(
6403            c_path.as_ptr(),
6404            meta_json.as_ptr(),
6405            ptr::null(),
6406            0,
6407            &mut enc,
6408        );
6409        assert!(matches!(err, super::TgmError::Ok));
6410
6411        let values = [7.0f32, 8.0];
6412        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
6413        let desc_json = CString::new(format!(
6414            r#"{{"type":"ntensor","ndim":1,"shape":[{len}],"strides":[1],"dtype":"float32","byte_order":"{bo}","encoding":"none","filter":"none","compression":"none"}}"#,
6415            len = values.len(),
6416            bo = if cfg!(target_endian = "little") { "little" } else { "big" },
6417        )).unwrap();
6418
6419        let err = super::tgm_streaming_encoder_write_pre_encoded(
6420            enc,
6421            desc_json.as_ptr(),
6422            data.as_ptr(),
6423            data.len(),
6424        );
6425        assert!(matches!(err, super::TgmError::Ok));
6426
6427        let err = super::tgm_streaming_encoder_finish(enc);
6428        assert!(matches!(err, super::TgmError::Ok));
6429        super::tgm_streaming_encoder_free(enc);
6430
6431        // Read back and verify
6432        let mut file: *mut super::TgmFile = ptr::null_mut();
6433        let err = super::tgm_file_open(c_path.as_ptr(), &mut file);
6434        assert!(matches!(err, super::TgmError::Ok));
6435
6436        let mut msg: *mut super::TgmMessage = ptr::null_mut();
6437        let err = super::tgm_file_decode_message(file, 0, 0, 0, 0, &mut msg);
6438        assert!(matches!(err, super::TgmError::Ok));
6439
6440        let mut dl: usize = 0;
6441        let dp = super::tgm_object_data(msg, 0, &mut dl);
6442        let decoded: Vec<f32> = unsafe { slice::from_raw_parts(dp, dl) }
6443            .chunks_exact(4)
6444            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
6445            .collect();
6446        assert_eq!(decoded, values);
6447
6448        super::tgm_message_free(msg);
6449        super::tgm_file_close(file);
6450        let _ = std::fs::remove_file(&path);
6451    }
6452
6453    // ── tgm_encode with invalid hash algo ──
6454
6455    #[test]
6456    fn ffi_encode_invalid_hash_algo() {
6457        let json = CString::new(r#"{"version":3,"descriptors":[]}"#).unwrap();
6458        let bad_algo = CString::new("bogus").unwrap();
6459        let mut out = super::TgmBytes {
6460            data: ptr::null_mut(),
6461            len: 0,
6462        };
6463
6464        let err = super::tgm_encode(
6465            json.as_ptr(),
6466            ptr::null(),
6467            ptr::null(),
6468            0,
6469            bad_algo.as_ptr(),
6470            0,
6471            &mut out,
6472        );
6473        assert!(matches!(err, super::TgmError::InvalidArg));
6474    }
6475
6476    // ── tgm_scan_entry OOB returns sentinel and sets error ──
6477
6478    #[test]
6479    fn ffi_scan_entry_oob_returns_sentinel() {
6480        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
6481
6482        let mut result: *mut super::TgmScanResult = ptr::null_mut();
6483        let err = super::tgm_scan(encoded.as_ptr(), encoded.len(), &mut result);
6484        assert!(matches!(err, super::TgmError::Ok));
6485
6486        assert_eq!(super::tgm_scan_count(result), 1);
6487
6488        // Valid index works
6489        let good = super::tgm_scan_entry(result, 0);
6490        assert_eq!(good.offset, 0);
6491        assert!(good.length > 0);
6492
6493        // OOB index returns sentinel
6494        let bad = super::tgm_scan_entry(result, 1);
6495        assert_eq!(bad.offset, usize::MAX);
6496        assert_eq!(bad.length, 0);
6497
6498        // Error message is set
6499        let err_ptr = super::tgm_last_error();
6500        assert!(!err_ptr.is_null());
6501        let err_str = unsafe { CStr::from_ptr(err_ptr) }.to_str().unwrap();
6502        assert!(
6503            err_str.contains("out of range"),
6504            "expected OOB error, got: {err_str}"
6505        );
6506
6507        super::tgm_scan_free(result);
6508    }
6509
6510    // ── collect_data_slices null ptr + len=0 safety ──
6511
6512    #[test]
6513    fn ffi_encode_zero_length_null_data_accepted() {
6514        // Encode with a zero-element tensor where the data pointer could be null
6515        // but length is 0 — should succeed without UB.
6516        let json = CString::new(
6517            r#"{"version":3,"descriptors":[{"type":"ntensor","ndim":1,"shape":[0],"strides":[1],"dtype":"float32","byte_order":"little","encoding":"none","filter":"none","compression":"none"}]}"#,
6518        )
6519        .unwrap();
6520        let data_ptrs: [*const u8; 1] = [ptr::null()];
6521        let data_lens: [usize; 1] = [0];
6522        let mut out = super::TgmBytes {
6523            data: ptr::null_mut(),
6524            len: 0,
6525        };
6526
6527        let err = super::tgm_encode(
6528            json.as_ptr(),
6529            data_ptrs.as_ptr(),
6530            data_lens.as_ptr(),
6531            1,
6532            ptr::null(), // no hash
6533            0,           // threads
6534            &mut out,
6535        );
6536        assert!(
6537            matches!(err, super::TgmError::Ok),
6538            "encoding zero-length data with null pointer should succeed"
6539        );
6540        if !out.data.is_null() {
6541            super::tgm_bytes_free(out);
6542        }
6543    }
6544
6545    // ═══ Coverage-closer tests ═════════════════════════════════════════
6546
6547    // ── tgm_validate (previously zero tests) ───────────────────────────
6548
6549    #[test]
6550    fn ffi_validate_null_out() {
6551        let err = super::tgm_validate(ptr::null(), 0, ptr::null(), 0, ptr::null_mut());
6552        assert!(matches!(err, super::TgmError::InvalidArg));
6553    }
6554
6555    #[test]
6556    fn ffi_validate_null_buf_with_nonzero_len() {
6557        let mut out = super::TgmBytes {
6558            data: ptr::null_mut(),
6559            len: 0,
6560        };
6561        let err = super::tgm_validate(ptr::null(), 42, ptr::null(), 0, &mut out);
6562        assert!(matches!(err, super::TgmError::InvalidArg));
6563    }
6564
6565    #[test]
6566    fn ffi_validate_empty_buffer_ok() {
6567        // buf=null, len=0 → valid empty-buffer validation
6568        let mut out = super::TgmBytes {
6569            data: ptr::null_mut(),
6570            len: 0,
6571        };
6572        let err = super::tgm_validate(ptr::null(), 0, ptr::null(), 0, &mut out);
6573        assert!(matches!(err, super::TgmError::Ok));
6574        assert!(!out.data.is_null());
6575        assert!(out.len > 0);
6576        super::tgm_bytes_free(out);
6577    }
6578
6579    #[test]
6580    fn ffi_validate_valid_message_all_levels() {
6581        let encoded = ffi_encode_single_f32_tensor(&[1.0f32, 2.0, 3.0, 4.0], "");
6582        for level_str in &["quick", "checksum", "default", "full"] {
6583            let level = CString::new(*level_str).unwrap();
6584            let mut out = super::TgmBytes {
6585                data: ptr::null_mut(),
6586                len: 0,
6587            };
6588            let err =
6589                super::tgm_validate(encoded.as_ptr(), encoded.len(), level.as_ptr(), 0, &mut out);
6590            assert!(matches!(err, super::TgmError::Ok), "level {level_str}");
6591            super::tgm_bytes_free(out);
6592        }
6593    }
6594
6595    #[test]
6596    fn ffi_validate_canonical_flag() {
6597        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
6598        let mut out = super::TgmBytes {
6599            data: ptr::null_mut(),
6600            len: 0,
6601        };
6602        let err = super::tgm_validate(
6603            encoded.as_ptr(),
6604            encoded.len(),
6605            ptr::null(),
6606            1, // check_canonical
6607            &mut out,
6608        );
6609        assert!(matches!(err, super::TgmError::Ok));
6610        super::tgm_bytes_free(out);
6611    }
6612
6613    #[test]
6614    fn ffi_validate_invalid_level_string() {
6615        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
6616        let bogus = CString::new("bogus-level-name").unwrap();
6617        let mut out = super::TgmBytes {
6618            data: ptr::null_mut(),
6619            len: 0,
6620        };
6621        let err = super::tgm_validate(encoded.as_ptr(), encoded.len(), bogus.as_ptr(), 0, &mut out);
6622        assert!(matches!(err, super::TgmError::InvalidArg));
6623    }
6624
6625    #[test]
6626    fn ffi_validate_garbage_reports_issues() {
6627        let garbage = [0xDEu8; 100];
6628        let mut out = super::TgmBytes {
6629            data: ptr::null_mut(),
6630            len: 0,
6631        };
6632        let err = super::tgm_validate(garbage.as_ptr(), garbage.len(), ptr::null(), 0, &mut out);
6633        assert!(matches!(err, super::TgmError::Ok));
6634        let json = unsafe { slice::from_raw_parts(out.data, out.len) };
6635        let s = std::str::from_utf8(json).unwrap();
6636        assert!(s.contains("issues"));
6637        super::tgm_bytes_free(out);
6638    }
6639
6640    // ── tgm_validate_file (previously zero tests) ──────────────────────
6641
6642    #[test]
6643    fn ffi_validate_file_null_args() {
6644        let mut out = super::TgmBytes {
6645            data: ptr::null_mut(),
6646            len: 0,
6647        };
6648        let err = super::tgm_validate_file(ptr::null(), ptr::null(), 0, &mut out);
6649        assert!(matches!(err, super::TgmError::InvalidArg));
6650        let path = CString::new("/tmp/x.tgm").unwrap();
6651        let err = super::tgm_validate_file(path.as_ptr(), ptr::null(), 0, ptr::null_mut());
6652        assert!(matches!(err, super::TgmError::InvalidArg));
6653    }
6654
6655    #[test]
6656    fn ffi_validate_file_nonexistent() {
6657        let path = CString::new("/nonexistent/path/to/missing-file.tgm").unwrap();
6658        let mut out = super::TgmBytes {
6659            data: ptr::null_mut(),
6660            len: 0,
6661        };
6662        let err = super::tgm_validate_file(path.as_ptr(), ptr::null(), 0, &mut out);
6663        assert!(matches!(err, super::TgmError::Io));
6664    }
6665
6666    #[test]
6667    fn ffi_validate_file_valid_round_trip() {
6668        use std::io::Write;
6669        let encoded = ffi_encode_single_f32_tensor(&[1.0f32, 2.0, 3.0], "");
6670        let tmp = std::env::temp_dir().join(format!(
6671            "tensogram-ffi-validate-file-{}.tgm",
6672            std::process::id(),
6673        ));
6674        std::fs::File::create(&tmp)
6675            .unwrap()
6676            .write_all(&encoded)
6677            .unwrap();
6678        let path = CString::new(tmp.to_str().unwrap()).unwrap();
6679        let mut out = super::TgmBytes {
6680            data: ptr::null_mut(),
6681            len: 0,
6682        };
6683        let err = super::tgm_validate_file(path.as_ptr(), ptr::null(), 0, &mut out);
6684        assert!(matches!(err, super::TgmError::Ok));
6685        super::tgm_bytes_free(out);
6686        let _ = std::fs::remove_file(&tmp);
6687    }
6688
6689    #[test]
6690    fn ffi_validate_file_invalid_level() {
6691        let path = CString::new("/tmp/dummy.tgm").unwrap();
6692        let level = CString::new("bogus").unwrap();
6693        let mut out = super::TgmBytes {
6694            data: ptr::null_mut(),
6695            len: 0,
6696        };
6697        let err = super::tgm_validate_file(path.as_ptr(), level.as_ptr(), 0, &mut out);
6698        assert!(matches!(err, super::TgmError::InvalidArg));
6699    }
6700
6701    // ── Inline-hash integrity via tgm_validate on tampered payload ──
6702
6703    /// Flipping a byte inside the payload region of a hashed
6704    /// message must surface a hash mismatch through
6705    /// `tgm_validate` at the `integrity` / `checksum` level (the
6706    /// v3 integrity channel — see `plans/WIRE_FORMAT.md` §11).
6707    ///
6708    /// `tgm_decode` in v3 does **not** verify frame-level hashes
6709    /// (decode is a pure deserialisation path); the `verify_hash`
6710    /// flag on decode is retained for source compatibility but is
6711    /// a no-op.  Hash integrity is always a validate-level check.
6712    #[test]
6713    fn ffi_validate_detects_tampered_payload() {
6714        let values = vec![1.0f32; 256];
6715        let encoded = ffi_encode_with_hash(&values);
6716        let mut tampered = encoded.clone();
6717        // Tamper ~75% into the message so we're in the payload
6718        // region, not the frame header / CBOR descriptor.
6719        let pos = (tampered.len() * 75) / 100;
6720        tampered[pos] ^= 0xFF;
6721        tampered[pos + 1] ^= 0xFF;
6722
6723        let mut out = super::TgmBytes {
6724            data: ptr::null_mut(),
6725            len: 0,
6726        };
6727        let level = CString::new("checksum").unwrap();
6728        let err = super::tgm_validate(
6729            tampered.as_ptr(),
6730            tampered.len(),
6731            level.as_ptr(),
6732            /* pretty */ 0,
6733            &mut out,
6734        );
6735        // Validation itself runs fine; the report JSON flags the
6736        // mismatch.  `tgm_validate` returns Ok on any parseable
6737        // message and communicates findings via the JSON report.
6738        assert!(matches!(err, super::TgmError::Ok));
6739        assert!(!out.data.is_null());
6740        let json_bytes = unsafe { slice::from_raw_parts(out.data, out.len) };
6741        let json = std::str::from_utf8(json_bytes).unwrap();
6742        assert!(
6743            json.contains("HashMismatch") || json.contains("hash mismatch"),
6744            "expected HashMismatch in validate report, got: {json}"
6745        );
6746        super::tgm_bytes_free(out);
6747    }
6748
6749    // ── tgm_object_data with null out_len pointer ─────────────────────
6750
6751    #[test]
6752    fn ffi_object_data_null_out_len_no_crash() {
6753        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
6754        let mut msg: *mut super::TgmMessage = ptr::null_mut();
6755        let err = super::tgm_decode(
6756            encoded.as_ptr(),
6757            encoded.len(),
6758            /* native_byte_order */ 0,
6759            /* threads */ 0,
6760            0, // verify_hash
6761            &mut msg,
6762        );
6763        assert!(matches!(err, super::TgmError::Ok));
6764        // Calling with null out_len must not crash
6765        let data = super::tgm_object_data(msg, 0, ptr::null_mut());
6766        assert!(!data.is_null());
6767        super::tgm_message_free(msg);
6768    }
6769
6770    // ── tgm_decode_range on a compression that lacks a block index ────
6771
6772    #[test]
6773    fn ffi_decode_range_on_compressed_without_offsets() {
6774        // Encode with zstd compression which doesn't support range decode
6775        // without block offsets.
6776        let json = CString::new(
6777            r#"{"version":3,"descriptors":[{"type":"ntensor","ndim":1,"shape":[100],"strides":[1],"dtype":"float32","byte_order":"little","encoding":"none","filter":"none","compression":"zstd"}]}"#,
6778        )
6779        .unwrap();
6780        let data: Vec<u8> = vec![0u8; 400];
6781        let data_ptr: *const u8 = data.as_ptr();
6782        let data_len = data.len();
6783        let mut out = super::TgmBytes {
6784            data: ptr::null_mut(),
6785            len: 0,
6786        };
6787        let err = super::tgm_encode(
6788            json.as_ptr(),
6789            &data_ptr as *const *const u8,
6790            &data_len as *const usize,
6791            1,
6792            ptr::null(),
6793            /* threads */ 0,
6794            &mut out,
6795        );
6796        assert!(matches!(err, super::TgmError::Ok));
6797        let encoded = unsafe { slice::from_raw_parts(out.data, out.len) }.to_vec();
6798        super::tgm_bytes_free(out);
6799
6800        // Attempt to range-decode: should fail because zstd has no block index.
6801        let range_offset: u64 = 10;
6802        let range_count: u64 = 20;
6803        let mut out_buf = super::TgmBytes {
6804            data: ptr::null_mut(),
6805            len: 0,
6806        };
6807        let mut out_count: usize = 0;
6808        let err = super::tgm_decode_range(
6809            encoded.as_ptr(),
6810            encoded.len(),
6811            0,
6812            &range_offset as *const u64,
6813            &range_count as *const u64,
6814            1,
6815            /* native_byte_order */ 0,
6816            /* threads */ 0,
6817            /* join */ 1,
6818            &mut out_buf,
6819            &mut out_count,
6820        );
6821        assert!(!matches!(err, super::TgmError::Ok));
6822    }
6823
6824    // ── tgm_simple_packing_compute_params edge cases ──
6825
6826    #[test]
6827    fn ffi_simple_packing_null_values() {
6828        let mut ref_val: f64 = 0.0;
6829        let mut bsf: i32 = 0;
6830        let err =
6831            super::tgm_simple_packing_compute_params(ptr::null(), 0, 16, 0, &mut ref_val, &mut bsf);
6832        assert!(matches!(err, super::TgmError::InvalidArg));
6833    }
6834
6835    #[test]
6836    fn ffi_simple_packing_null_out_ref() {
6837        let values: [f64; 3] = [1.0, 2.0, 3.0];
6838        let mut bsf: i32 = 0;
6839        let err = super::tgm_simple_packing_compute_params(
6840            values.as_ptr(),
6841            3,
6842            16,
6843            0,
6844            ptr::null_mut(),
6845            &mut bsf,
6846        );
6847        assert!(matches!(err, super::TgmError::InvalidArg));
6848    }
6849
6850    #[test]
6851    fn ffi_simple_packing_null_out_bsf() {
6852        let values: [f64; 3] = [1.0, 2.0, 3.0];
6853        let mut ref_val: f64 = 0.0;
6854        let err = super::tgm_simple_packing_compute_params(
6855            values.as_ptr(),
6856            3,
6857            16,
6858            0,
6859            &mut ref_val,
6860            ptr::null_mut(),
6861        );
6862        assert!(matches!(err, super::TgmError::InvalidArg));
6863    }
6864
6865    // ── _with_options FFI coverage ─────────────────────────────────────
6866
6867    /// `tgm_encode_with_options(NULL mask_options)` behaves identically
6868    /// to `tgm_encode` — exercises the mask-options NULL-pointer path.
6869    #[test]
6870    fn ffi_encode_with_options_null_mask_ptr() {
6871        let values = [1.0f32, 2.0, 3.0, 4.0];
6872        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
6873        let json = format!(
6874            r#"{{"version":3,"descriptors":[{{"type":"ntensor","ndim":1,"shape":[{}],"strides":[1],"dtype":"float32","byte_order":"{}","encoding":"none","filter":"none","compression":"none"}}]}}"#,
6875            values.len(),
6876            if cfg!(target_endian = "little") {
6877                "little"
6878            } else {
6879                "big"
6880            },
6881        );
6882        let c_json = CString::new(json).unwrap();
6883        let hash_algo = CString::new("xxh3").unwrap();
6884        let data_ptr: *const u8 = data.as_ptr();
6885        let data_len: usize = data.len();
6886
6887        let mut out = super::TgmBytes {
6888            data: ptr::null_mut(),
6889            len: 0,
6890        };
6891        let err = super::tgm_encode_with_options(
6892            c_json.as_ptr(),
6893            &data_ptr as *const *const u8,
6894            &data_len as *const usize,
6895            1,
6896            hash_algo.as_ptr(),
6897            0,           // threads
6898            ptr::null(), // mask_options = NULL → defaults
6899            &mut out,
6900        );
6901        assert!(matches!(err, super::TgmError::Ok));
6902        assert!(!out.data.is_null() && out.len > 0);
6903        super::tgm_bytes_free(out);
6904    }
6905
6906    /// `tgm_decode_with_options(NULL mask_options)` behaves identically
6907    /// to `tgm_decode` — exercises the NULL-pointer branch of
6908    /// `apply_decode_mask_options`.
6909    #[test]
6910    fn ffi_decode_with_options_null_mask_ptr() {
6911        let values = [1.0f32, 2.0];
6912        let encoded = ffi_encode_single_f32_tensor(&values, "");
6913
6914        let mut msg: *mut super::TgmMessage = ptr::null_mut();
6915        let err = super::tgm_decode_with_options(
6916            encoded.as_ptr(),
6917            encoded.len(),
6918            0,
6919            0,
6920            0,           // verify_hash
6921            ptr::null(), // mask_options = NULL → default restore_non_finite=true
6922            &mut msg,
6923        );
6924        assert!(matches!(err, super::TgmError::Ok));
6925        assert!(!msg.is_null());
6926        super::tgm_message_free(msg);
6927    }
6928
6929    /// Non-NULL `TgmDecodeMaskOptions` with `restore_non_finite = false`
6930    /// threads through to `DecodeOptions` — pins the
6931    /// `apply_decode_mask_options` mutation path.
6932    #[test]
6933    fn ffi_decode_with_options_explicit_restore_false() {
6934        let values = [1.0f32, 2.0];
6935        let encoded = ffi_encode_single_f32_tensor(&values, "");
6936
6937        let mask_opts = super::TgmDecodeMaskOptions {
6938            restore_non_finite: false,
6939        };
6940        let mut msg: *mut super::TgmMessage = ptr::null_mut();
6941        let err = super::tgm_decode_with_options(
6942            encoded.as_ptr(),
6943            encoded.len(),
6944            0,
6945            0,
6946            0, // verify_hash
6947            &mask_opts,
6948            &mut msg,
6949        );
6950        assert!(matches!(err, super::TgmError::Ok));
6951        super::tgm_message_free(msg);
6952    }
6953
6954    /// `tgm_decode_with_options` with NULL output pointer must
6955    /// return InvalidArg and set `tgm_last_error`.
6956    #[test]
6957    fn ffi_decode_with_options_null_out() {
6958        let err =
6959            super::tgm_decode_with_options(b"x".as_ptr(), 1, 0, 0, 0, ptr::null(), ptr::null_mut());
6960        assert!(matches!(err, super::TgmError::InvalidArg));
6961        let msg = unsafe { CStr::from_ptr(super::tgm_last_error()) }
6962            .to_str()
6963            .unwrap();
6964        assert!(msg.contains("null"), "expected null-arg msg, got: {msg}");
6965    }
6966
6967    // ── tgm_doctor_to_json ──
6968
6969    #[test]
6970    fn ffi_doctor_to_json_returns_parseable_json() {
6971        let mut out = super::TgmBytes {
6972            data: ptr::null_mut(),
6973            len: 0,
6974        };
6975        let err = super::tgm_doctor_to_json(&mut out);
6976        assert!(matches!(err, super::TgmError::Ok));
6977        assert!(!out.data.is_null());
6978        assert!(out.len > 0);
6979
6980        let json_bytes = unsafe { slice::from_raw_parts(out.data, out.len) };
6981        let json_str = std::str::from_utf8(json_bytes).expect("doctor JSON is UTF-8");
6982        let parsed: serde_json::Value = serde_json::from_str(json_str).expect("doctor JSON parses");
6983
6984        // Schema parity with Python / WASM / Rust core: same three top-level keys.
6985        let obj = parsed.as_object().expect("top-level object");
6986        for key in ["build", "features", "self_test"] {
6987            assert!(obj.contains_key(key), "missing key '{key}' in: {obj:?}");
6988        }
6989
6990        super::tgm_bytes_free(out);
6991    }
6992
6993    #[test]
6994    fn ffi_doctor_to_json_null_out() {
6995        let err = super::tgm_doctor_to_json(ptr::null_mut());
6996        assert!(matches!(err, super::TgmError::InvalidArg));
6997        let msg = unsafe { CStr::from_ptr(super::tgm_last_error()) }
6998            .to_str()
6999            .unwrap();
7000        assert!(msg.contains("null"), "expected null-arg msg, got: {msg}");
7001    }
7002}
7003
7004/// Compute a hash of the given data.
7005/// Returns `TGM_ERROR_OK` on success, fills `out` with a `tgm_bytes_t`
7006/// containing the hex-encoded hash string (NOT null-terminated).
7007/// Free with `tgm_bytes_free`.
7008#[unsafe(no_mangle)]
7009pub extern "C" fn tgm_compute_hash(
7010    data: *const u8,
7011    data_len: usize,
7012    algo: *const c_char,
7013    out: *mut TgmBytes,
7014) -> TgmError {
7015    if data.is_null() || out.is_null() {
7016        set_last_error("null argument");
7017        return TgmError::InvalidArg;
7018    }
7019
7020    // v3 has exactly one algorithm; the FFI accepts NULL, "xxh3" or
7021    // "none" through `parse_hash_algo`.  When the caller passes
7022    // "none" we still compute and return the xxh3 digest because
7023    // `tgm_compute_hash` is the standalone "compute a digest" entry
7024    // point — there is no "no hash" output for it; the only thing
7025    // strict-input gets us is rejecting bogus algorithm names like
7026    // "sha256" with a clear error.
7027    if !algo.is_null() {
7028        let s = match unsafe { CStr::from_ptr(algo) }.to_str() {
7029            Ok(s) => s,
7030            Err(_) => {
7031                set_last_error("invalid UTF-8 in algo");
7032                return TgmError::InvalidArg;
7033            }
7034        };
7035        if let Err(e) = parse_hash_name(Some(s)) {
7036            set_last_error(&e.to_string());
7037            return TgmError::InvalidArg;
7038        }
7039    }
7040
7041    let input = unsafe { slice::from_raw_parts(data, data_len) };
7042    let hex = tensogram::hash::compute_hash(input);
7043    // Rebuild via boxed slice to guarantee capacity == len for tgm_bytes_free.
7044    let mut bytes = hex.into_bytes().into_boxed_slice().into_vec();
7045    let result = TgmBytes {
7046        data: bytes.as_mut_ptr(),
7047        len: bytes.len(),
7048    };
7049    std::mem::forget(bytes);
7050    unsafe {
7051        *out = result;
7052    }
7053    TgmError::Ok
7054}
7055
7056// ---------------------------------------------------------------------------
7057// Doctor: environment diagnostics
7058// ---------------------------------------------------------------------------
7059
7060/// Run environment diagnostics and serialise the report as a JSON byte buffer.
7061///
7062/// The report mirrors `tensogram::doctor::run_diagnostics()` and the
7063/// `tensogram doctor` CLI subcommand.  Cross-language parity: the same
7064/// JSON shape is produced by the Python `tensogram.doctor()` and the
7065/// WASM `doctor()` exports — see `docs/src/cli/doctor.md` for the
7066/// schema.  The C FFI build does **not** run the GRIB or NetCDF
7067/// converter self-tests (those features are CLI-only), so the
7068/// `self_test` array covers only the core encode/decode pipeline plus
7069/// the codecs compiled into the dylib.
7070///
7071/// On success returns `TgmError::Ok` and fills `out` with a JSON
7072/// payload (UTF-8, NOT null-terminated).  Use `tgm_bytes_free` to
7073/// release it.  Callers can safely treat `out.data` as a `char*` of
7074/// length `out.len` and pass it to `json_loads` / `nlohmann::json::parse`
7075/// / equivalent.
7076///
7077/// On serialisation failure returns `TgmError::Encoding` and writes a
7078/// human-readable description retrievable via `tgm_last_error()`.
7079///
7080/// # Example
7081///
7082/// ```c
7083/// tgm_bytes_t report = {0};
7084/// if (tgm_doctor_to_json(&report) == TGM_ERROR_OK) {
7085///     fwrite(report.data, 1, report.len, stdout);
7086///     tgm_bytes_free(report);
7087/// }
7088/// ```
7089#[unsafe(no_mangle)]
7090pub extern "C" fn tgm_doctor_to_json(out: *mut TgmBytes) -> TgmError {
7091    if out.is_null() {
7092        set_last_error("null out pointer");
7093        return TgmError::InvalidArg;
7094    }
7095
7096    let report = tensogram::doctor::run_diagnostics();
7097    let json = match serde_json::to_string(&report) {
7098        Ok(s) => s,
7099        Err(e) => {
7100            set_last_error(&format!("failed to serialise doctor report: {e}"));
7101            return TgmError::Encoding;
7102        }
7103    };
7104
7105    // Rebuild via boxed slice to guarantee capacity == len for tgm_bytes_free.
7106    let mut bytes = json.into_bytes().into_boxed_slice().into_vec();
7107    let result = TgmBytes {
7108        data: bytes.as_mut_ptr(),
7109        len: bytes.len(),
7110    };
7111    std::mem::forget(bytes); // ownership transferred to C
7112    unsafe {
7113        *out = result;
7114    }
7115    TgmError::Ok
7116}
7117
7118// ---------------------------------------------------------------------------
7119// Streaming encoder
7120// ---------------------------------------------------------------------------
7121
7122/// Opaque handle for a streaming encoder that writes data objects progressively.
7123pub struct TgmStreamingEncoder {
7124    inner: Option<StreamingEncoder<std::io::BufWriter<std::fs::File>>>,
7125}
7126
7127/// JSON used for streaming encoder creation — optional extra/base keys.
7128///
7129/// A legacy top-level `"version"` field is tolerated for pre-0.17
7130/// schema compatibility and routed into `_extra_` on encode, matching
7131/// the free-form contract of every other binding.  The wire-format
7132/// version itself lives in the preamble (see
7133/// `plans/WIRE_FORMAT.md` §3).
7134#[derive(serde::Deserialize)]
7135struct StreamingEncodeJson {
7136    #[serde(default)]
7137    version: Option<u16>,
7138    #[serde(default)]
7139    base: Vec<BTreeMap<String, serde_json::Value>>,
7140    #[serde(flatten)]
7141    extra: BTreeMap<String, serde_json::Value>,
7142}
7143
7144/// Parse metadata JSON for the streaming encoder (no "descriptors" key).
7145pub(crate) fn parse_streaming_metadata_json(json_str: &str) -> Result<GlobalMetadata, String> {
7146    let parsed: StreamingEncodeJson = serde_json::from_str(json_str)
7147        .map_err(|e| format!("failed to parse metadata JSON: {e}"))?;
7148
7149    let cbor_base: Vec<BTreeMap<String, ciborium::Value>> = parsed
7150        .base
7151        .into_iter()
7152        .map(|entry| {
7153            entry
7154                .into_iter()
7155                .map(|(k, v)| (k, json_to_cbor(v)))
7156                .collect()
7157        })
7158        .collect();
7159
7160    // Validate: no _reserved_ keys in base entries (library-managed namespace)
7161    for (i, entry) in cbor_base.iter().enumerate() {
7162        if entry.contains_key(RESERVED_KEY) {
7163            return Err(format!(
7164                "base[{i}] must not contain '{RESERVED_KEY}' key — the encoder populates it"
7165            ));
7166        }
7167    }
7168
7169    // Route explicit `_extra_` / legacy `version` under the shared
7170    // free-form merge rule.  Matches the `parse_encode_json` path and
7171    // the Python / TypeScript / Rust-core contract.
7172    let cbor_extra = merge_flattened_extras_with_version(parsed.extra, parsed.version)?;
7173    Ok(GlobalMetadata {
7174        base: cbor_base,
7175        extra: cbor_extra,
7176        ..Default::default()
7177    })
7178}
7179
7180/// Create a streaming encoder writing to a file.
7181///
7182/// `metadata_json` is a free-form JSON object.  The `"descriptors"`
7183/// key is NOT permitted here (objects are supplied one at a time
7184/// via `tgm_streaming_encoder_write`).  A legacy top-level
7185/// `"version"` is tolerated and routed into `_extra_` on encode
7186/// (see `parse_streaming_metadata_json`).
7187///
7188/// `hash_algo`: null-terminated string ("xxh3") or NULL for no hash.
7189///
7190/// On success fills `out` with a `TgmStreamingEncoder` handle.
7191/// Free with `tgm_streaming_encoder_free` or finalize with
7192/// `tgm_streaming_encoder_finish`.
7193#[unsafe(no_mangle)]
7194pub extern "C" fn tgm_streaming_encoder_create(
7195    path: *const c_char,
7196    metadata_json: *const c_char,
7197    hash_algo: *const c_char,
7198    threads: u32,
7199    out: *mut *mut TgmStreamingEncoder,
7200) -> TgmError {
7201    if path.is_null() || metadata_json.is_null() || out.is_null() {
7202        set_last_error("null argument");
7203        return TgmError::InvalidArg;
7204    }
7205
7206    let path_str = match unsafe { CStr::from_ptr(path) }.to_str() {
7207        Ok(s) => s,
7208        Err(e) => {
7209            set_last_error(&format!("invalid UTF-8 in path: {e}"));
7210            return TgmError::InvalidArg;
7211        }
7212    };
7213
7214    let json_str = match unsafe { CStr::from_ptr(metadata_json) }.to_str() {
7215        Ok(s) => s,
7216        Err(e) => {
7217            set_last_error(&format!("invalid UTF-8 in metadata_json: {e}"));
7218            return TgmError::InvalidArg;
7219        }
7220    };
7221
7222    let global_metadata = match parse_streaming_metadata_json(json_str) {
7223        Ok(m) => m,
7224        Err(e) => {
7225            set_last_error(&e);
7226            return TgmError::Metadata;
7227        }
7228    };
7229
7230    let hashing = match parse_hash_algo(hash_algo) {
7231        Ok(b) => b,
7232        Err((code, msg)) => {
7233            set_last_error(&msg);
7234            return code;
7235        }
7236    };
7237
7238    let file = match std::fs::File::create(path_str) {
7239        Ok(f) => f,
7240        Err(e) => {
7241            set_last_error(&e.to_string());
7242            return TgmError::Io;
7243        }
7244    };
7245
7246    let options = EncodeOptions {
7247        hashing,
7248        threads,
7249        ..Default::default()
7250    };
7251    let writer = std::io::BufWriter::new(file);
7252
7253    match StreamingEncoder::new(writer, &global_metadata, &options) {
7254        Ok(enc) => {
7255            let handle = Box::new(TgmStreamingEncoder { inner: Some(enc) });
7256            unsafe {
7257                *out = Box::into_raw(handle);
7258            }
7259            TgmError::Ok
7260        }
7261        Err(e) => {
7262            set_last_error(&e.to_string());
7263            to_error_code(&e)
7264        }
7265    }
7266}
7267
7268/// Write a PrecederMetadata frame for the next data object.
7269///
7270/// `metadata_json` is a JSON object with per-object metadata keys
7271/// (e.g. `{"mars": {"param": "2t"}, "units": "K"}`).  The keys
7272/// become `payload[0]` in a GlobalMetadata CBOR with empty `common`.
7273///
7274/// Must be followed by exactly one `tgm_streaming_encoder_write` call
7275/// before another preceder or `tgm_streaming_encoder_finish`.
7276#[unsafe(no_mangle)]
7277pub extern "C" fn tgm_streaming_encoder_write_preceder(
7278    enc: *mut TgmStreamingEncoder,
7279    metadata_json: *const c_char,
7280) -> TgmError {
7281    if enc.is_null() || metadata_json.is_null() {
7282        set_last_error("null argument");
7283        return TgmError::InvalidArg;
7284    }
7285
7286    let json_str = match unsafe { CStr::from_ptr(metadata_json) }.to_str() {
7287        Ok(s) => s,
7288        Err(e) => {
7289            set_last_error(&format!("invalid UTF-8 in metadata_json: {e}"));
7290            return TgmError::InvalidArg;
7291        }
7292    };
7293
7294    let map: BTreeMap<String, ciborium::Value> =
7295        match serde_json::from_str::<serde_json::Value>(json_str) {
7296            Ok(serde_json::Value::Object(obj)) => {
7297                obj.into_iter().map(|(k, v)| (k, json_to_cbor(v))).collect()
7298            }
7299            Ok(_) => {
7300                set_last_error("metadata_json must be a JSON object");
7301                return TgmError::Metadata;
7302            }
7303            Err(e) => {
7304                set_last_error(&format!("failed to parse metadata JSON: {e}"));
7305                return TgmError::Metadata;
7306            }
7307        };
7308
7309    let encoder = unsafe { &mut *enc };
7310    match encoder.inner.as_mut() {
7311        Some(inner) => match inner.write_preceder(map) {
7312            Ok(()) => TgmError::Ok,
7313            Err(e) => {
7314                set_last_error(&e.to_string());
7315                to_error_code(&e)
7316            }
7317        },
7318        None => {
7319            set_last_error("streaming encoder already finished");
7320            TgmError::InvalidArg
7321        }
7322    }
7323}
7324
7325/// Write a single data object to the streaming encoder.
7326///
7327/// `descriptor_json` is a JSON object with the descriptor fields
7328/// (type, ndim, shape, strides, dtype, byte_order, encoding, filter,
7329/// compression, etc.).
7330#[unsafe(no_mangle)]
7331pub extern "C" fn tgm_streaming_encoder_write(
7332    enc: *mut TgmStreamingEncoder,
7333    descriptor_json: *const c_char,
7334    data: *const u8,
7335    data_len: usize,
7336) -> TgmError {
7337    if enc.is_null() || descriptor_json.is_null() || data.is_null() {
7338        set_last_error("null argument");
7339        return TgmError::InvalidArg;
7340    }
7341
7342    let json_str = match unsafe { CStr::from_ptr(descriptor_json) }.to_str() {
7343        Ok(s) => s,
7344        Err(e) => {
7345            set_last_error(&format!("invalid UTF-8 in descriptor_json: {e}"));
7346            return TgmError::InvalidArg;
7347        }
7348    };
7349
7350    let descriptor: DataObjectDescriptor = match serde_json::from_str(json_str) {
7351        Ok(d) => d,
7352        Err(e) => {
7353            set_last_error(&format!("failed to parse descriptor JSON: {e}"));
7354            return TgmError::Metadata;
7355        }
7356    };
7357
7358    let data_slice = unsafe { slice::from_raw_parts(data, data_len) };
7359    let encoder = unsafe { &mut *enc };
7360
7361    match encoder.inner.as_mut() {
7362        Some(inner) => match inner.write_object(&descriptor, data_slice) {
7363            Ok(()) => TgmError::Ok,
7364            Err(e) => {
7365                set_last_error(&e.to_string());
7366                to_error_code(&e)
7367            }
7368        },
7369        None => {
7370            set_last_error("streaming encoder already finished");
7371            TgmError::InvalidArg
7372        }
7373    }
7374}
7375
7376/// Write a single pre-encoded data object to the streaming encoder.
7377///
7378/// Like `tgm_streaming_encoder_write`, but `data` must already be encoded
7379/// according to the descriptor's pipeline (`encoding` / `filter` /
7380/// `compression`). The library does not run the encoding pipeline — it
7381/// validates the descriptor's pipeline configuration and writes the bytes
7382/// as-is into a data object frame. The hash (if configured on the encoder)
7383/// is recomputed over the caller's bytes.
7384///
7385/// `descriptor_json`: same JSON schema as `tgm_streaming_encoder_write`.
7386///
7387/// For `szip` compression, callers SHOULD include `szip_block_offsets`
7388/// (bit offsets, not byte offsets) in the descriptor's params so that
7389/// `tgm_decode_range` can locate compressed block boundaries later.
7390/// Other pipeline params (e.g. `simple_packing` reference value, scale
7391/// factors) must also be present in the descriptor.
7392///
7393/// Any `hash` field embedded in the descriptor JSON is ignored — the
7394/// library always recomputes the hash from the caller's bytes.
7395#[unsafe(no_mangle)]
7396pub extern "C" fn tgm_streaming_encoder_write_pre_encoded(
7397    enc: *mut TgmStreamingEncoder,
7398    descriptor_json: *const c_char,
7399    data: *const u8,
7400    data_len: usize,
7401) -> TgmError {
7402    if enc.is_null() || descriptor_json.is_null() || data.is_null() {
7403        set_last_error("null argument");
7404        return TgmError::InvalidArg;
7405    }
7406
7407    let json_str = match unsafe { CStr::from_ptr(descriptor_json) }.to_str() {
7408        Ok(s) => s,
7409        Err(e) => {
7410            set_last_error(&format!("invalid UTF-8 in descriptor_json: {e}"));
7411            return TgmError::InvalidArg;
7412        }
7413    };
7414
7415    let descriptor: DataObjectDescriptor = match serde_json::from_str(json_str) {
7416        Ok(d) => d,
7417        Err(e) => {
7418            set_last_error(&format!("failed to parse descriptor JSON: {e}"));
7419            return TgmError::Metadata;
7420        }
7421    };
7422
7423    let data_slice = unsafe { slice::from_raw_parts(data, data_len) };
7424    let encoder = unsafe { &mut *enc };
7425
7426    match encoder.inner.as_mut() {
7427        Some(inner) => match inner.write_object_pre_encoded(&descriptor, data_slice) {
7428            Ok(()) => TgmError::Ok,
7429            Err(e) => {
7430                set_last_error(&e.to_string());
7431                to_error_code(&e)
7432            }
7433        },
7434        None => {
7435            set_last_error("streaming encoder already finished");
7436            TgmError::InvalidArg
7437        }
7438    }
7439}
7440
7441/// Return the number of objects written so far.
7442#[unsafe(no_mangle)]
7443pub extern "C" fn tgm_streaming_encoder_count(enc: *const TgmStreamingEncoder) -> usize {
7444    if enc.is_null() {
7445        return 0;
7446    }
7447    unsafe { (*enc).inner.as_ref().map(|e| e.object_count()).unwrap_or(0) }
7448}
7449
7450/// Finalize the streaming encoder, writing footer and closing the file.
7451///
7452/// After calling this, the handle is still valid but empty — the caller
7453/// must still call `tgm_streaming_encoder_free` to release it.
7454#[unsafe(no_mangle)]
7455pub extern "C" fn tgm_streaming_encoder_finish(enc: *mut TgmStreamingEncoder) -> TgmError {
7456    if enc.is_null() {
7457        set_last_error("null argument");
7458        return TgmError::InvalidArg;
7459    }
7460
7461    let encoder = unsafe { &mut *enc };
7462    match encoder.inner.take() {
7463        Some(inner) => match inner.finish() {
7464            Ok(_writer) => {
7465                // Writer is dropped, file is closed.
7466                // Do NOT free enc — caller must call tgm_streaming_encoder_free.
7467                TgmError::Ok
7468            }
7469            Err(e) => {
7470                set_last_error(&e.to_string());
7471                to_error_code(&e)
7472            }
7473        },
7474        None => {
7475            set_last_error("streaming encoder already finished");
7476            TgmError::InvalidArg
7477        }
7478    }
7479}
7480
7481/// Free a streaming encoder without finalizing (abandons the output).
7482#[unsafe(no_mangle)]
7483pub extern "C" fn tgm_streaming_encoder_free(enc: *mut TgmStreamingEncoder) {
7484    if !enc.is_null() {
7485        unsafe {
7486            drop(Box::from_raw(enc));
7487        }
7488    }
7489}
7490
7491// ---------------------------------------------------------------------------
7492// Validation
7493// ---------------------------------------------------------------------------
7494
7495/// Parse a C-string validation level into `ValidateOptions`.
7496fn parse_validate_options(
7497    level: *const c_char,
7498    check_canonical: i32,
7499) -> Result<ValidateOptions, (TgmError, String)> {
7500    let level_str = if level.is_null() {
7501        "default"
7502    } else {
7503        unsafe { CStr::from_ptr(level) }
7504            .to_str()
7505            .map_err(|_| (TgmError::InvalidArg, "invalid UTF-8 in level".to_string()))?
7506    };
7507
7508    let (max_level, checksum_only) = match level_str {
7509        "quick" => (ValidationLevel::Structure, false),
7510        "default" => (ValidationLevel::Integrity, false),
7511        "checksum" => (ValidationLevel::Integrity, true),
7512        "full" => (ValidationLevel::Fidelity, false),
7513        other => {
7514            return Err((
7515                TgmError::InvalidArg,
7516                format!(
7517                    "unknown validation level: '{}', expected one of: quick, default, checksum, full",
7518                    other
7519                ),
7520            ));
7521        }
7522    };
7523
7524    Ok(ValidateOptions {
7525        max_level,
7526        check_canonical: check_canonical != 0,
7527        checksum_only,
7528    })
7529}
7530
7531/// Validate a single Tensogram message buffer.
7532///
7533/// `buf` / `buf_len`: the wire-format message bytes (single message).
7534///   `buf` may be NULL when `buf_len` is 0 (empty-buffer validation).
7535/// `level`: validation depth — null-terminated C string:
7536///   `"quick"` (structure only), `"default"` (up to hash check),
7537///   `"checksum"` (hash check, suppress structural warnings),
7538///   `"full"` (full decode + NaN/Inf scan). NULL defaults to `"default"`.
7539/// `check_canonical`: non-zero to check RFC 8949 CBOR key ordering.
7540/// `out`: receives UTF-8 JSON bytes describing the validation report.
7541///   Not NUL-terminated — use `out->len` for the byte count.
7542///   Free with `tgm_bytes_free`.
7543///
7544/// Returns `TGM_ERROR_OK` on success (even if the message has issues —
7545/// the issues are in the JSON report). Returns `TGM_ERROR_INVALID_ARG`
7546/// for argument validation failures (null pointers, invalid level string),
7547/// or `TGM_ERROR_ENCODING` if JSON serialization of the report fails.
7548#[unsafe(no_mangle)]
7549pub extern "C" fn tgm_validate(
7550    buf: *const u8,
7551    buf_len: usize,
7552    level: *const c_char,
7553    check_canonical: i32,
7554    out: *mut TgmBytes,
7555) -> TgmError {
7556    if out.is_null() {
7557        set_last_error("null argument");
7558        return TgmError::InvalidArg;
7559    }
7560    // Allow buf=NULL when buf_len=0 (empty-buffer validation).
7561    if buf.is_null() && buf_len > 0 {
7562        set_last_error("null buf with non-zero buf_len");
7563        return TgmError::InvalidArg;
7564    }
7565
7566    let options = match parse_validate_options(level, check_canonical) {
7567        Ok(o) => o,
7568        Err((code, msg)) => {
7569            set_last_error(&msg);
7570            return code;
7571        }
7572    };
7573
7574    let data = if buf.is_null() {
7575        &[]
7576    } else {
7577        unsafe { slice::from_raw_parts(buf, buf_len) }
7578    };
7579    let report = validate_message(data, &options);
7580
7581    match serde_json::to_vec(&report) {
7582        Ok(json_bytes) => {
7583            let mut json_bytes = json_bytes.into_boxed_slice().into_vec();
7584            let result = TgmBytes {
7585                data: json_bytes.as_mut_ptr(),
7586                len: json_bytes.len(),
7587            };
7588            std::mem::forget(json_bytes);
7589            unsafe {
7590                *out = result;
7591            }
7592            TgmError::Ok
7593        }
7594        Err(e) => {
7595            set_last_error(&format!("JSON serialization failed: {e}"));
7596            TgmError::Encoding
7597        }
7598    }
7599}
7600
7601/// Validate all messages in a `.tgm` file.
7602///
7603/// `path`: null-terminated UTF-8 path to the file.
7604/// `level`: validation depth (same as `tgm_validate`). NULL = `"default"`.
7605/// `check_canonical`: non-zero to check CBOR key ordering.
7606/// `out`: receives UTF-8 JSON bytes describing the file validation report.
7607///   Not NUL-terminated — use `out->len` for the byte count.
7608///   Free with `tgm_bytes_free`.
7609///
7610/// Returns `TGM_ERROR_OK` on success (issues are in the JSON).
7611/// Returns `TGM_ERROR_IO` if the file cannot be opened or read.
7612/// Returns `TGM_ERROR_INVALID_ARG` for null pointers or invalid level.
7613/// Returns `TGM_ERROR_ENCODING` if JSON serialization of the report fails.
7614#[unsafe(no_mangle)]
7615pub extern "C" fn tgm_validate_file(
7616    path: *const c_char,
7617    level: *const c_char,
7618    check_canonical: i32,
7619    out: *mut TgmBytes,
7620) -> TgmError {
7621    if path.is_null() || out.is_null() {
7622        set_last_error("null argument");
7623        return TgmError::InvalidArg;
7624    }
7625
7626    let path_str = match unsafe { CStr::from_ptr(path) }.to_str() {
7627        Ok(s) => s,
7628        Err(e) => {
7629            set_last_error(&format!("invalid UTF-8 in path: {e}"));
7630            return TgmError::InvalidArg;
7631        }
7632    };
7633
7634    let options = match parse_validate_options(level, check_canonical) {
7635        Ok(o) => o,
7636        Err((code, msg)) => {
7637            set_last_error(&msg);
7638            return code;
7639        }
7640    };
7641
7642    let report = match core_validate_file(Path::new(path_str), &options) {
7643        Ok(r) => r,
7644        Err(e) => {
7645            set_last_error(&e.to_string());
7646            return TgmError::Io;
7647        }
7648    };
7649
7650    match serde_json::to_vec(&report) {
7651        Ok(json_bytes) => {
7652            let mut json_bytes = json_bytes.into_boxed_slice().into_vec();
7653            let result = TgmBytes {
7654                data: json_bytes.as_mut_ptr(),
7655                len: json_bytes.len(),
7656            };
7657            std::mem::forget(json_bytes);
7658            unsafe {
7659                *out = result;
7660            }
7661            TgmError::Ok
7662        }
7663        Err(e) => {
7664            set_last_error(&format!("JSON serialization failed: {e}"));
7665            TgmError::Encoding
7666        }
7667    }
7668}