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/// Returns NULL if the key is not found or is not a string.
1869/// The pointer is valid until the metadata handle is freed.
1870#[unsafe(no_mangle)]
1871pub extern "C" fn tgm_metadata_get_string(
1872    meta: *const TgmMetadata,
1873    key: *const c_char,
1874) -> *const c_char {
1875    if meta.is_null() || key.is_null() {
1876        return ptr::null();
1877    }
1878
1879    let key_str = match unsafe { CStr::from_ptr(key) }.to_str() {
1880        Ok(s) => s,
1881        Err(_) => return ptr::null(),
1882    };
1883
1884    let m = unsafe { &(*meta) };
1885    let value = lookup_string_key(&m.global_metadata, key_str);
1886
1887    match value {
1888        Some(s) => {
1889            let mut cache = m.cache.borrow_mut();
1890            let entry = cache
1891                .entry(key_str.to_string())
1892                .or_insert_with(|| CString::new(s.clone()).unwrap_or_default());
1893            entry.as_ptr()
1894        }
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#[unsafe(no_mangle)]
1902pub extern "C" fn tgm_metadata_get_int(
1903    meta: *const TgmMetadata,
1904    key: *const c_char,
1905    default_val: i64,
1906) -> i64 {
1907    if meta.is_null() || key.is_null() {
1908        return default_val;
1909    }
1910
1911    let key_str = match unsafe { CStr::from_ptr(key) }.to_str() {
1912        Ok(s) => s,
1913        Err(_) => return default_val,
1914    };
1915
1916    let m = unsafe { &(*meta) };
1917    lookup_int_key(&m.global_metadata, key_str).unwrap_or(default_val)
1918}
1919
1920/// Look up a float value by dot-notation key.
1921#[unsafe(no_mangle)]
1922pub extern "C" fn tgm_metadata_get_float(
1923    meta: *const TgmMetadata,
1924    key: *const c_char,
1925    default_val: f64,
1926) -> f64 {
1927    if meta.is_null() || key.is_null() {
1928        return default_val;
1929    }
1930
1931    let key_str = match unsafe { CStr::from_ptr(key) }.to_str() {
1932        Ok(s) => s,
1933        Err(_) => return default_val,
1934    };
1935
1936    let m = unsafe { &(*meta) };
1937    lookup_float_key(&m.global_metadata, key_str).unwrap_or(default_val)
1938}
1939
1940/// Free a metadata handle.
1941#[unsafe(no_mangle)]
1942pub extern "C" fn tgm_metadata_free(meta: *mut TgmMetadata) {
1943    if !meta.is_null() {
1944        unsafe {
1945            drop(Box::from_raw(meta));
1946        }
1947    }
1948}
1949
1950// ---------------------------------------------------------------------------
1951// File API
1952// ---------------------------------------------------------------------------
1953
1954/// Open an existing Tensogram file for reading.
1955#[unsafe(no_mangle)]
1956pub extern "C" fn tgm_file_open(path: *const c_char, out: *mut *mut TgmFile) -> TgmError {
1957    if path.is_null() || out.is_null() {
1958        set_last_error("null argument");
1959        return TgmError::InvalidArg;
1960    }
1961
1962    let path_str = match unsafe { CStr::from_ptr(path) }.to_str() {
1963        Ok(s) => s,
1964        Err(e) => {
1965            set_last_error(&format!("invalid UTF-8 in path: {e}"));
1966            return TgmError::InvalidArg;
1967        }
1968    };
1969
1970    match TensogramFile::open(path_str) {
1971        Ok(file) => {
1972            let path_string = CString::new(path_str).unwrap_or_default();
1973            let handle = Box::new(TgmFile { file, path_string });
1974            unsafe {
1975                *out = Box::into_raw(handle);
1976            }
1977            TgmError::Ok
1978        }
1979        Err(e) => {
1980            set_last_error(&e.to_string());
1981            to_error_code(&e)
1982        }
1983    }
1984}
1985
1986/// Create a new Tensogram file for writing.
1987#[unsafe(no_mangle)]
1988pub extern "C" fn tgm_file_create(path: *const c_char, out: *mut *mut TgmFile) -> TgmError {
1989    if path.is_null() || out.is_null() {
1990        set_last_error("null argument");
1991        return TgmError::InvalidArg;
1992    }
1993
1994    let path_str = match unsafe { CStr::from_ptr(path) }.to_str() {
1995        Ok(s) => s,
1996        Err(e) => {
1997            set_last_error(&format!("invalid UTF-8 in path: {e}"));
1998            return TgmError::InvalidArg;
1999        }
2000    };
2001
2002    match TensogramFile::create(path_str) {
2003        Ok(file) => {
2004            let path_string = CString::new(path_str).unwrap_or_default();
2005            let handle = Box::new(TgmFile { file, path_string });
2006            unsafe {
2007                *out = Box::into_raw(handle);
2008            }
2009            TgmError::Ok
2010        }
2011        Err(e) => {
2012            set_last_error(&e.to_string());
2013            to_error_code(&e)
2014        }
2015    }
2016}
2017
2018/// Count messages in the file (may trigger lazy scan).
2019#[unsafe(no_mangle)]
2020pub extern "C" fn tgm_file_message_count(file: *mut TgmFile, out_count: *mut usize) -> TgmError {
2021    if file.is_null() || out_count.is_null() {
2022        set_last_error("null argument");
2023        return TgmError::InvalidArg;
2024    }
2025
2026    let f = unsafe { &(*file).file };
2027    match f.message_count() {
2028        Ok(count) => {
2029            unsafe {
2030                *out_count = count;
2031            }
2032            TgmError::Ok
2033        }
2034        Err(e) => {
2035            set_last_error(&e.to_string());
2036            to_error_code(&e)
2037        }
2038    }
2039}
2040
2041/// Decode message at `index` from the file.
2042/// On success fills `out` with a `TgmMessage` handle.
2043#[unsafe(no_mangle)]
2044pub extern "C" fn tgm_file_decode_message(
2045    file: *mut TgmFile,
2046    index: usize,
2047    native_byte_order: i32,
2048    threads: u32,
2049    verify_hash: i32,
2050    out: *mut *mut TgmMessage,
2051) -> TgmError {
2052    if file.is_null() || out.is_null() {
2053        set_last_error("null argument");
2054        return TgmError::InvalidArg;
2055    }
2056
2057    let f = unsafe { &(*file).file };
2058    let options = DecodeOptions {
2059        native_byte_order: native_byte_order != 0,
2060        threads,
2061        verify_hash: verify_hash != 0,
2062        ..Default::default()
2063    };
2064
2065    match f.decode_message(index, &options) {
2066        Ok((global_metadata, objects)) => {
2067            // Inline hashes: re-read the raw message bytes and run
2068            // them through the cheap frame-header walker.  This
2069            // costs one extra message read (typically memory-
2070            // mapped) but gives FFI file-path callers parity with
2071            // the buffer path's hash accessors.  Silent fallback
2072            // to empty on read error — `decode_message` above
2073            // already succeeded so this branch is defensive.
2074            let inline_hashes = f
2075                .read_message(index)
2076                .ok()
2077                .and_then(|bytes| tensogram::framing::data_object_inline_hashes(&bytes).ok())
2078                .unwrap_or_default();
2079            let caches = build_message_caches(&objects, &inline_hashes);
2080            let msg = Box::new(TgmMessage {
2081                global_metadata,
2082                objects,
2083                dtype_strings: caches.dtype_strings,
2084                type_strings: caches.type_strings,
2085                byte_order_strings: caches.byte_order_strings,
2086                filter_strings: caches.filter_strings,
2087                compression_strings: caches.compression_strings,
2088                encoding_strings: caches.encoding_strings,
2089                hash_type_strings: caches.hash_type_strings,
2090                hash_value_strings: caches.hash_value_strings,
2091            });
2092            unsafe {
2093                *out = Box::into_raw(msg);
2094            }
2095            TgmError::Ok
2096        }
2097        Err(e) => {
2098            set_last_error(&e.to_string());
2099            to_error_code(&e)
2100        }
2101    }
2102}
2103
2104/// Read raw message bytes at `index`.
2105/// On success fills `out` with a `TgmBytes` buffer.
2106#[unsafe(no_mangle)]
2107pub extern "C" fn tgm_file_read_message(
2108    file: *mut TgmFile,
2109    index: usize,
2110    out: *mut TgmBytes,
2111) -> TgmError {
2112    if file.is_null() || out.is_null() {
2113        set_last_error("null argument");
2114        return TgmError::InvalidArg;
2115    }
2116
2117    let f = unsafe { &(*file).file };
2118
2119    match f.read_message(index) {
2120        Ok(bytes) => {
2121            // Rebuild via boxed slice to guarantee capacity == len for tgm_bytes_free.
2122            let mut bytes = bytes.into_boxed_slice().into_vec();
2123            let result = TgmBytes {
2124                data: bytes.as_mut_ptr(),
2125                len: bytes.len(),
2126            };
2127            std::mem::forget(bytes);
2128            unsafe {
2129                *out = result;
2130            }
2131            TgmError::Ok
2132        }
2133        Err(e) => {
2134            set_last_error(&e.to_string());
2135            to_error_code(&e)
2136        }
2137    }
2138}
2139
2140/// Append raw message bytes to the file.
2141#[unsafe(no_mangle)]
2142pub extern "C" fn tgm_file_append_raw(
2143    file: *mut TgmFile,
2144    buf: *const u8,
2145    buf_len: usize,
2146) -> TgmError {
2147    if file.is_null() || buf.is_null() {
2148        set_last_error("null argument");
2149        return TgmError::InvalidArg;
2150    }
2151
2152    let data = unsafe { slice::from_raw_parts(buf, buf_len) };
2153    let f = unsafe { &mut (*file).file };
2154
2155    // Write raw bytes using std::fs
2156    use std::io::Write;
2157    let path = match f.path() {
2158        Some(p) => p.to_path_buf(),
2159        None => {
2160            set_last_error("append_raw not supported on remote files");
2161            return TgmError::Remote;
2162        }
2163    };
2164    let result = std::fs::OpenOptions::new()
2165        .create(true)
2166        .append(true)
2167        .open(&path)
2168        .and_then(|mut fh| fh.write_all(data));
2169
2170    match result {
2171        Ok(()) => {
2172            f.invalidate_offsets();
2173            TgmError::Ok
2174        }
2175        Err(e) => {
2176            set_last_error(&e.to_string());
2177            TgmError::Io
2178        }
2179    }
2180}
2181
2182/// Returns the file path as a null-terminated string.
2183/// The pointer is valid until the file handle is closed.
2184#[unsafe(no_mangle)]
2185pub extern "C" fn tgm_file_path(file: *const TgmFile) -> *const c_char {
2186    if file.is_null() {
2187        return ptr::null();
2188    }
2189    unsafe { (*file).path_string.as_ptr() }
2190}
2191
2192/// Encode and append a message to the file.
2193/// Same JSON schema as `tgm_encode` for `metadata_json`.
2194///
2195/// Non-finite-value rejection is on by default in 0.17+; see `tgm_encode`.
2196#[unsafe(no_mangle)]
2197pub extern "C" fn tgm_file_append(
2198    file: *mut TgmFile,
2199    metadata_json: *const c_char,
2200    data_ptrs: *const *const u8,
2201    data_lens: *const usize,
2202    num_objects: usize,
2203    hash_algo: *const c_char,
2204    threads: u32,
2205) -> TgmError {
2206    if file.is_null() || metadata_json.is_null() {
2207        set_last_error("null argument");
2208        return TgmError::InvalidArg;
2209    }
2210
2211    let json_str = match unsafe { CStr::from_ptr(metadata_json) }.to_str() {
2212        Ok(s) => s,
2213        Err(e) => {
2214            set_last_error(&format!("invalid UTF-8 in metadata_json: {e}"));
2215            return TgmError::InvalidArg;
2216        }
2217    };
2218
2219    let parsed = match unsafe {
2220        parse_encode_args(
2221            json_str,
2222            data_ptrs,
2223            data_lens,
2224            num_objects,
2225            hash_algo,
2226            threads,
2227        )
2228    } {
2229        Ok(p) => p,
2230        Err((code, msg)) => {
2231            set_last_error(&msg);
2232            return code;
2233        }
2234    };
2235
2236    let pairs: Vec<(&DataObjectDescriptor, &[u8])> = parsed
2237        .descriptors
2238        .iter()
2239        .zip(parsed.data_slices.iter())
2240        .map(|(d, s)| (d, *s))
2241        .collect();
2242
2243    let f = unsafe { &mut (*file).file };
2244    match f.append(&parsed.global_metadata, &pairs, &parsed.options) {
2245        Ok(()) => TgmError::Ok,
2246        Err(e) => {
2247            set_last_error(&e.to_string());
2248            to_error_code(&e)
2249        }
2250    }
2251}
2252
2253/// Close a file handle and release resources.
2254#[unsafe(no_mangle)]
2255pub extern "C" fn tgm_file_close(file: *mut TgmFile) {
2256    if !file.is_null() {
2257        unsafe {
2258            drop(Box::from_raw(file));
2259        }
2260    }
2261}
2262
2263// ---------------------------------------------------------------------------
2264// Metadata key lookup helpers
2265// ---------------------------------------------------------------------------
2266
2267/// Look up a CBOR value by dot-notation key with arbitrary nesting depth.
2268///
2269/// Supports `"key"`, `"ns.field"`, `"grib.geography.Ni"`, etc.
2270/// Search order: `base[i]` (skip `_reserved_`, first match) → `extra`.
2271fn lookup_cbor_value<'a>(
2272    global_metadata: &'a GlobalMetadata,
2273    key: &str,
2274) -> Option<&'a ciborium::Value> {
2275    if key.is_empty() {
2276        return None;
2277    }
2278    let parts: Vec<&str> = key.split('.').collect();
2279
2280    if parts.is_empty() || parts[0].is_empty() {
2281        return None;
2282    }
2283    if parts[0] == "version" {
2284        return None; // use tgm_metadata_version instead
2285    }
2286
2287    // Explicit _extra_ or extra prefix targets the extra map directly
2288    if parts[0] == "_extra_" || parts[0] == "extra" {
2289        if parts.len() > 1 {
2290            return resolve_in_btree(&global_metadata.extra, &parts[1..]);
2291        }
2292        return None;
2293    }
2294
2295    // Search base entries (skip _reserved_ key within each entry)
2296    for entry in &global_metadata.base {
2297        if let Some(val) = resolve_in_btree_skip_reserved(entry, &parts) {
2298            return Some(val);
2299        }
2300    }
2301    // Fall back to extra
2302    resolve_in_btree(&global_metadata.extra, &parts)
2303}
2304
2305/// Walk a dot-path in a BTreeMap, skipping `_reserved_` keys at the first level.
2306fn resolve_in_btree_skip_reserved<'a>(
2307    map: &'a BTreeMap<String, ciborium::Value>,
2308    parts: &[&str],
2309) -> Option<&'a ciborium::Value> {
2310    let (first, rest) = parts.split_first()?;
2311    if *first == RESERVED_KEY {
2312        return None;
2313    }
2314    let value = map.get(*first)?;
2315    resolve_cbor_path(value, rest)
2316}
2317
2318/// Walk a dot-path in a BTreeMap (no _reserved_ filtering).
2319fn resolve_in_btree<'a>(
2320    map: &'a BTreeMap<String, ciborium::Value>,
2321    parts: &[&str],
2322) -> Option<&'a ciborium::Value> {
2323    let (first, rest) = parts.split_first()?;
2324    let value = map.get(*first)?;
2325    resolve_cbor_path(value, rest)
2326}
2327
2328/// Recursively walk remaining path segments into a CBOR value.
2329///
2330/// When no segments remain, returns the current value.
2331/// When segments remain, the current value must be a `Map` to navigate further.
2332fn resolve_cbor_path<'a>(
2333    value: &'a ciborium::Value,
2334    remaining: &[&str],
2335) -> Option<&'a ciborium::Value> {
2336    if remaining.is_empty() {
2337        return Some(value);
2338    }
2339    if let ciborium::Value::Map(entries) = value {
2340        for (k, v) in entries {
2341            if matches!(k, ciborium::Value::Text(s) if s == remaining[0]) {
2342                return resolve_cbor_path(v, &remaining[1..]);
2343            }
2344        }
2345    }
2346    None
2347}
2348
2349fn lookup_string_key(global_metadata: &GlobalMetadata, key: &str) -> Option<String> {
2350    if key.is_empty() {
2351        return None;
2352    }
2353    // `version` is a pseudo-key — the wire-format version lives in the
2354    // preamble (see `plans/WIRE_FORMAT.md` §3), not in the CBOR metadata
2355    // frame.  Return the constant so FFI tooling that queries the key
2356    // keeps seeing `"3"`.
2357    if key == "version" {
2358        return Some(tensogram::WIRE_VERSION.to_string());
2359    }
2360
2361    lookup_cbor_value(global_metadata, key).and_then(|v| match v {
2362        ciborium::Value::Text(s) => Some(s.clone()),
2363        ciborium::Value::Integer(i) => {
2364            let n: i128 = (*i).into();
2365            Some(n.to_string())
2366        }
2367        ciborium::Value::Float(f) => Some(f.to_string()),
2368        ciborium::Value::Bool(b) => Some(b.to_string()),
2369        _ => None,
2370    })
2371}
2372
2373fn lookup_int_key(global_metadata: &GlobalMetadata, key: &str) -> Option<i64> {
2374    // `version` pseudo-key — see `lookup_string_key` for rationale.
2375    if key == "version" {
2376        return Some(tensogram::WIRE_VERSION as i64);
2377    }
2378
2379    lookup_cbor_value(global_metadata, key).and_then(|v| match v {
2380        ciborium::Value::Integer(i) => {
2381            let n: i128 = (*i).into();
2382            i64::try_from(n).ok()
2383        }
2384        _ => None,
2385    })
2386}
2387
2388fn lookup_float_key(global_metadata: &GlobalMetadata, key: &str) -> Option<f64> {
2389    lookup_cbor_value(global_metadata, key).and_then(|v| match v {
2390        ciborium::Value::Float(f) => Some(*f),
2391        ciborium::Value::Integer(i) => {
2392            let n: i128 = (*i).into();
2393            // i128 → f64 may lose precision for very large integers, but this
2394            // is the expected behavior for a float accessor on an integer value.
2395            Some(n as f64)
2396        }
2397        _ => None,
2398    })
2399}
2400
2401// ---------------------------------------------------------------------------
2402// simple_packing direct access
2403// ---------------------------------------------------------------------------
2404
2405/// Compute simple_packing parameters for a set of f64 values.
2406///
2407/// Returns TgmError::Ok on success, filling the out-params.
2408/// Returns Encoding error if data contains NaN.
2409#[unsafe(no_mangle)]
2410pub extern "C" fn tgm_simple_packing_compute_params(
2411    values: *const f64,
2412    num_values: usize,
2413    bits_per_value: u32,
2414    decimal_scale_factor: i32,
2415    out_reference_value: *mut f64,
2416    out_binary_scale_factor: *mut i32,
2417) -> TgmError {
2418    if values.is_null() || out_reference_value.is_null() || out_binary_scale_factor.is_null() {
2419        set_last_error("null argument");
2420        return TgmError::InvalidArg;
2421    }
2422
2423    let vals = unsafe { slice::from_raw_parts(values, num_values) };
2424
2425    match tensogram_encodings::simple_packing::compute_params(
2426        vals,
2427        bits_per_value,
2428        decimal_scale_factor,
2429    ) {
2430        Ok(params) => {
2431            unsafe {
2432                *out_reference_value = params.reference_value;
2433                *out_binary_scale_factor = params.binary_scale_factor;
2434            }
2435            TgmError::Ok
2436        }
2437        Err(e) => {
2438            set_last_error(&e.to_string());
2439            TgmError::Encoding
2440        }
2441    }
2442}
2443
2444// ---------------------------------------------------------------------------
2445// Iterator API
2446// ---------------------------------------------------------------------------
2447
2448/// Opaque handle for iterating over messages in a byte buffer.
2449///
2450/// The caller's buffer must remain valid for the lifetime of this iterator.
2451pub struct TgmBufferIter {
2452    offsets: Vec<(usize, usize)>,
2453    buf_ptr: *const u8,
2454    pos: usize,
2455}
2456
2457/// Create a buffer message iterator.
2458///
2459/// Scans `buf` once and stores message boundaries. The buffer must remain
2460/// valid and unmodified until `tgm_buffer_iter_free` is called.
2461#[unsafe(no_mangle)]
2462pub extern "C" fn tgm_buffer_iter_create(
2463    buf: *const u8,
2464    buf_len: usize,
2465    out: *mut *mut TgmBufferIter,
2466) -> TgmError {
2467    if buf.is_null() || out.is_null() {
2468        set_last_error("null argument");
2469        return TgmError::InvalidArg;
2470    }
2471    let data = unsafe { slice::from_raw_parts(buf, buf_len) };
2472    let offsets = scan(data);
2473    let iter = Box::new(TgmBufferIter {
2474        offsets,
2475        buf_ptr: buf,
2476        pos: 0,
2477    });
2478    unsafe {
2479        *out = Box::into_raw(iter);
2480    }
2481    TgmError::Ok
2482}
2483
2484/// Return the total number of messages in the buffer iterator.
2485#[unsafe(no_mangle)]
2486pub extern "C" fn tgm_buffer_iter_count(iter: *const TgmBufferIter) -> usize {
2487    if iter.is_null() {
2488        return 0;
2489    }
2490    unsafe { (*iter).offsets.len() }
2491}
2492
2493/// Advance the buffer iterator. On success, sets `out_buf` and `out_len` to
2494/// the next message slice (borrowed from the original buffer).
2495///
2496/// Returns `TgmError::Ok` if a message is available, `TgmError::EndOfIter`
2497/// when iteration is exhausted.
2498#[unsafe(no_mangle)]
2499pub extern "C" fn tgm_buffer_iter_next(
2500    iter: *mut TgmBufferIter,
2501    out_buf: *mut *const u8,
2502    out_len: *mut usize,
2503) -> TgmError {
2504    if iter.is_null() || out_buf.is_null() || out_len.is_null() {
2505        set_last_error("null argument");
2506        return TgmError::InvalidArg;
2507    }
2508    let it = unsafe { &mut *iter };
2509    if it.pos >= it.offsets.len() {
2510        return TgmError::EndOfIter;
2511    }
2512    let (offset, length) = it.offsets[it.pos];
2513    it.pos += 1;
2514    unsafe {
2515        *out_buf = it.buf_ptr.add(offset);
2516        *out_len = length;
2517    }
2518    TgmError::Ok
2519}
2520
2521/// Free a buffer iterator handle.
2522#[unsafe(no_mangle)]
2523pub extern "C" fn tgm_buffer_iter_free(iter: *mut TgmBufferIter) {
2524    if !iter.is_null() {
2525        unsafe {
2526            drop(Box::from_raw(iter));
2527        }
2528    }
2529}
2530
2531/// Opaque handle for iterating over messages in a file.
2532pub struct TgmFileIter {
2533    inner: tensogram::FileMessageIter,
2534}
2535
2536/// Create a file message iterator from an open TgmFile.
2537///
2538/// Scans the file to locate message boundaries. The file handle remains
2539/// usable after this call.
2540#[unsafe(no_mangle)]
2541pub extern "C" fn tgm_file_iter_create(file: *mut TgmFile, out: *mut *mut TgmFileIter) -> TgmError {
2542    if file.is_null() || out.is_null() {
2543        set_last_error("null argument");
2544        return TgmError::InvalidArg;
2545    }
2546    let f = unsafe { &(*file).file };
2547    match f.iter() {
2548        Ok(inner) => {
2549            let iter = Box::new(TgmFileIter { inner });
2550            unsafe {
2551                *out = Box::into_raw(iter);
2552            }
2553            TgmError::Ok
2554        }
2555        Err(e) => {
2556            set_last_error(&e.to_string());
2557            to_error_code(&e)
2558        }
2559    }
2560}
2561
2562/// Advance the file iterator. On success, fills `out` with a `TgmBytes`
2563/// buffer containing the raw message bytes (caller owns, free with
2564/// `tgm_bytes_free`).
2565///
2566/// Returns `TgmError::Ok` when a message is available, `TgmError::EndOfIter`
2567/// when iteration is exhausted.
2568#[unsafe(no_mangle)]
2569pub extern "C" fn tgm_file_iter_next(iter: *mut TgmFileIter, out: *mut TgmBytes) -> TgmError {
2570    if iter.is_null() || out.is_null() {
2571        set_last_error("null argument");
2572        return TgmError::InvalidArg;
2573    }
2574    let it = unsafe { &mut (*iter).inner };
2575    match it.next() {
2576        None => TgmError::EndOfIter,
2577        Some(Err(e)) => {
2578            set_last_error(&e.to_string());
2579            to_error_code(&e)
2580        }
2581        Some(Ok(bytes)) => {
2582            // Rebuild via boxed slice to guarantee capacity == len for tgm_bytes_free.
2583            let mut bytes = bytes.into_boxed_slice().into_vec();
2584            let result = TgmBytes {
2585                data: bytes.as_mut_ptr(),
2586                len: bytes.len(),
2587            };
2588            std::mem::forget(bytes);
2589            unsafe {
2590                *out = result;
2591            }
2592            TgmError::Ok
2593        }
2594    }
2595}
2596
2597/// Free a file iterator handle.
2598#[unsafe(no_mangle)]
2599pub extern "C" fn tgm_file_iter_free(iter: *mut TgmFileIter) {
2600    if !iter.is_null() {
2601        unsafe {
2602            drop(Box::from_raw(iter));
2603        }
2604    }
2605}
2606
2607/// Opaque handle for iterating over objects within a single message.
2608pub struct TgmObjectIter {
2609    inner: tensogram::ObjectIter,
2610    /// Global metadata parsed from the message header, cloned into each
2611    /// yielded `TgmMessage` to preserve the original version and extra fields.
2612    global_metadata: GlobalMetadata,
2613}
2614
2615/// Create an object iterator from raw message bytes.
2616///
2617/// Parses metadata once, then decodes each object on demand when
2618/// `tgm_object_iter_next` is called. The global metadata from the
2619/// original message is preserved in each yielded `TgmMessage`.
2620#[unsafe(no_mangle)]
2621pub extern "C" fn tgm_object_iter_create(
2622    buf: *const u8,
2623    buf_len: usize,
2624    native_byte_order: i32,
2625    verify_hash: i32,
2626    out: *mut *mut TgmObjectIter,
2627) -> TgmError {
2628    if buf.is_null() || out.is_null() {
2629        set_last_error("null argument");
2630        return TgmError::InvalidArg;
2631    }
2632    let data = unsafe { slice::from_raw_parts(buf, buf_len) };
2633    let options = DecodeOptions {
2634        native_byte_order: native_byte_order != 0,
2635        verify_hash: verify_hash != 0,
2636        ..Default::default()
2637    };
2638
2639    // Parse global metadata from the message header so we can attach it to
2640    // each yielded TgmMessage instead of fabricating a default.
2641    let global_metadata = decode_metadata(data).unwrap_or_default();
2642
2643    match tensogram::objects(data, options) {
2644        Ok(inner) => {
2645            let iter = Box::new(TgmObjectIter {
2646                inner,
2647                global_metadata,
2648            });
2649            unsafe {
2650                *out = Box::into_raw(iter);
2651            }
2652            TgmError::Ok
2653        }
2654        Err(e) => {
2655            set_last_error(&e.to_string());
2656            to_error_code(&e)
2657        }
2658    }
2659}
2660
2661/// Advance the object iterator. On success, fills `out` with a `TgmMessage`
2662/// handle containing exactly one decoded object (the next in sequence).
2663///
2664/// Returns `TgmError::Ok` when an object is available, `TgmError::EndOfIter`
2665/// when iteration is exhausted. Free each yielded `TgmMessage` with
2666/// `tgm_message_free`.
2667#[unsafe(no_mangle)]
2668pub extern "C" fn tgm_object_iter_next(
2669    iter: *mut TgmObjectIter,
2670    out: *mut *mut TgmMessage,
2671) -> TgmError {
2672    if iter.is_null() || out.is_null() {
2673        set_last_error("null argument");
2674        return TgmError::InvalidArg;
2675    }
2676    let it = unsafe { &mut *iter };
2677    match it.inner.next() {
2678        None => TgmError::EndOfIter,
2679        Some(Err(e)) => {
2680            set_last_error(&e.to_string());
2681            to_error_code(&e)
2682        }
2683        Some(Ok((descriptor, data))) => {
2684            let global_metadata = it.global_metadata.clone();
2685            let objects = vec![(descriptor, data)];
2686            // Iterator path: the object iterator's `data` is the
2687            // already-decoded payload; the original frame's inline
2688            // hash slot isn't accessible from this layer without
2689            // re-reading from the source and re-scanning.  Callers
2690            // that need per-object hashes should either use
2691            // `tgm_file_decode_message` (which surfaces the hash
2692            // via the file-re-read path), or the buffer-based
2693            // `tgm_decode` if the raw bytes are already in memory.
2694            let caches = build_message_caches(&objects, &[]);
2695            let msg = Box::new(TgmMessage {
2696                global_metadata,
2697                objects,
2698                dtype_strings: caches.dtype_strings,
2699                type_strings: caches.type_strings,
2700                byte_order_strings: caches.byte_order_strings,
2701                filter_strings: caches.filter_strings,
2702                compression_strings: caches.compression_strings,
2703                encoding_strings: caches.encoding_strings,
2704                hash_type_strings: caches.hash_type_strings,
2705                hash_value_strings: caches.hash_value_strings,
2706            });
2707            unsafe {
2708                *out = Box::into_raw(msg);
2709            }
2710            TgmError::Ok
2711        }
2712    }
2713}
2714
2715/// Free an object iterator handle.
2716#[unsafe(no_mangle)]
2717pub extern "C" fn tgm_object_iter_free(iter: *mut TgmObjectIter) {
2718    if !iter.is_null() {
2719        unsafe {
2720            drop(Box::from_raw(iter));
2721        }
2722    }
2723}
2724
2725// ---------------------------------------------------------------------------
2726// Error code to string
2727// ---------------------------------------------------------------------------
2728
2729/// Convert an error code to a human-readable string.
2730/// Returns a static string (always valid, never NULL).
2731///
2732/// Accepts a raw integer and matches by value so that invalid discriminants
2733/// from C callers do not trigger undefined behaviour in Rust.
2734#[unsafe(no_mangle)]
2735pub extern "C" fn tgm_error_string(err: TgmError) -> *const c_char {
2736    // Convert to integer for safe matching — C callers may pass invalid values.
2737    let code = err as i32;
2738    let s: &[u8] = match code {
2739        0 => b"ok\0",
2740        1 => b"framing error\0",
2741        2 => b"metadata error\0",
2742        3 => b"encoding error\0",
2743        4 => b"compression error\0",
2744        5 => b"object error\0",
2745        6 => b"I/O error\0",
2746        7 => b"hash mismatch\0",
2747        8 => b"invalid argument\0",
2748        9 => b"end of iteration\0",
2749        10 => b"remote error\0",
2750        11 => b"missing hash\0",
2751        12 => b"async task timed out\0",
2752        13 => b"async task cancelled\0",
2753        _ => b"unknown error\0",
2754    };
2755    s.as_ptr() as *const c_char
2756}
2757
2758// ---------------------------------------------------------------------------
2759// Hash utilities
2760// ---------------------------------------------------------------------------
2761
2762// ---------------------------------------------------------------------------
2763// Unit tests for metadata lookup helpers and JSON parsing
2764// ---------------------------------------------------------------------------
2765
2766#[cfg(test)]
2767mod tests {
2768    use super::*;
2769    use std::collections::BTreeMap;
2770
2771    fn make_meta(
2772        base: Vec<BTreeMap<String, ciborium::Value>>,
2773        extra: BTreeMap<String, ciborium::Value>,
2774    ) -> GlobalMetadata {
2775        GlobalMetadata {
2776            base,
2777            extra,
2778            ..Default::default()
2779        }
2780    }
2781
2782    // ── lookup_cbor_value ─────────────────────────────────────────────
2783
2784    #[test]
2785    fn lookup_cbor_empty_key() {
2786        let meta = make_meta(vec![], BTreeMap::new());
2787        assert!(lookup_cbor_value(&meta, "").is_none());
2788    }
2789
2790    #[test]
2791    fn lookup_cbor_dot_only() {
2792        let meta = make_meta(vec![], BTreeMap::new());
2793        assert!(lookup_cbor_value(&meta, ".").is_none());
2794    }
2795
2796    #[test]
2797    fn lookup_cbor_version_returns_none() {
2798        // version is handled by tgm_metadata_version, not lookup_cbor_value
2799        let meta = make_meta(vec![], BTreeMap::new());
2800        assert!(lookup_cbor_value(&meta, "version").is_none());
2801    }
2802
2803    #[test]
2804    fn lookup_cbor_base_match() {
2805        let mut entry = BTreeMap::new();
2806        entry.insert("centre".into(), ciborium::Value::Text("ecmwf".into()));
2807        let meta = make_meta(vec![entry], BTreeMap::new());
2808        let val = lookup_cbor_value(&meta, "centre");
2809        assert!(matches!(val, Some(ciborium::Value::Text(s)) if s == "ecmwf"));
2810    }
2811
2812    #[test]
2813    fn lookup_cbor_extra_fallback() {
2814        // Key not in base → found in extra
2815        let mut extra = BTreeMap::new();
2816        extra.insert("source".into(), ciborium::Value::Text("test".into()));
2817        let meta = make_meta(vec![], extra);
2818        let val = lookup_cbor_value(&meta, "source");
2819        assert!(matches!(val, Some(ciborium::Value::Text(s)) if s == "test"));
2820    }
2821
2822    #[test]
2823    fn lookup_cbor_no_match() {
2824        let meta = make_meta(vec![], BTreeMap::new());
2825        assert!(lookup_cbor_value(&meta, "nonexistent").is_none());
2826    }
2827
2828    #[test]
2829    fn lookup_cbor_reserved_skipped() {
2830        let mut entry = BTreeMap::new();
2831        entry.insert(
2832            "_reserved_".into(),
2833            ciborium::Value::Map(vec![(
2834                ciborium::Value::Text("tensor".into()),
2835                ciborium::Value::Text("internal".into()),
2836            )]),
2837        );
2838        entry.insert("param".into(), ciborium::Value::Text("2t".into()));
2839        let meta = make_meta(vec![entry], BTreeMap::new());
2840        // _reserved_ path should be skipped
2841        assert!(lookup_cbor_value(&meta, "_reserved_.tensor").is_none());
2842        // Regular key should still be found
2843        assert!(lookup_cbor_value(&meta, "param").is_some());
2844    }
2845
2846    #[test]
2847    fn lookup_cbor_extra_prefix() {
2848        let mut extra = BTreeMap::new();
2849        extra.insert("custom".into(), ciborium::Value::Text("val".into()));
2850        let meta = make_meta(vec![], extra);
2851        // _extra_.custom should resolve directly in extra
2852        let val = lookup_cbor_value(&meta, "_extra_.custom");
2853        assert!(matches!(val, Some(ciborium::Value::Text(s)) if s == "val"));
2854    }
2855
2856    #[test]
2857    fn lookup_cbor_extra_alias_prefix() {
2858        let mut extra = BTreeMap::new();
2859        extra.insert("custom".into(), ciborium::Value::Text("val".into()));
2860        let meta = make_meta(vec![], extra);
2861        // extra.custom should also resolve in extra
2862        let val = lookup_cbor_value(&meta, "extra.custom");
2863        assert!(matches!(val, Some(ciborium::Value::Text(s)) if s == "val"));
2864    }
2865
2866    #[test]
2867    fn lookup_cbor_extra_prefix_alone_returns_none() {
2868        let meta = make_meta(vec![], BTreeMap::new());
2869        // Bare "_extra_" without subkey returns None
2870        assert!(lookup_cbor_value(&meta, "_extra_").is_none());
2871        assert!(lookup_cbor_value(&meta, "extra").is_none());
2872    }
2873
2874    #[test]
2875    fn lookup_cbor_base_wins_over_extra() {
2876        let mut entry = BTreeMap::new();
2877        entry.insert("shared".into(), ciborium::Value::Text("from_base".into()));
2878        let mut extra = BTreeMap::new();
2879        extra.insert("shared".into(), ciborium::Value::Text("from_extra".into()));
2880        let meta = make_meta(vec![entry], extra);
2881        let val = lookup_cbor_value(&meta, "shared");
2882        assert!(matches!(val, Some(ciborium::Value::Text(s)) if s == "from_base"));
2883    }
2884
2885    #[test]
2886    fn lookup_cbor_deeply_nested() {
2887        let e_val = ciborium::Value::Map(vec![(
2888            ciborium::Value::Text("e".into()),
2889            ciborium::Value::Text("deep".into()),
2890        )]);
2891        let d_val = ciborium::Value::Map(vec![(ciborium::Value::Text("d".into()), e_val)]);
2892        let c_val = ciborium::Value::Map(vec![(ciborium::Value::Text("c".into()), d_val)]);
2893        let b_val = ciborium::Value::Map(vec![(ciborium::Value::Text("b".into()), c_val)]);
2894        let mut entry = BTreeMap::new();
2895        entry.insert("a".into(), b_val);
2896        let meta = make_meta(vec![entry], BTreeMap::new());
2897        let val = lookup_cbor_value(&meta, "a.b.c.d.e");
2898        assert!(matches!(val, Some(ciborium::Value::Text(s)) if s == "deep"));
2899    }
2900
2901    #[test]
2902    fn lookup_cbor_multi_base_first_match() {
2903        let mut entry0 = BTreeMap::new();
2904        entry0.insert("param".into(), ciborium::Value::Text("2t".into()));
2905        let mut entry1 = BTreeMap::new();
2906        entry1.insert("param".into(), ciborium::Value::Text("msl".into()));
2907        let meta = make_meta(vec![entry0, entry1], BTreeMap::new());
2908        let val = lookup_cbor_value(&meta, "param");
2909        assert!(matches!(val, Some(ciborium::Value::Text(s)) if s == "2t"));
2910    }
2911
2912    // ── resolve_cbor_path ─────────────────────────────────────────────
2913
2914    #[test]
2915    fn resolve_cbor_path_empty_remaining() {
2916        let value = ciborium::Value::Text("hello".into());
2917        assert_eq!(resolve_cbor_path(&value, &[]), Some(&value));
2918    }
2919
2920    #[test]
2921    fn resolve_cbor_path_non_map_with_remaining() {
2922        let value = ciborium::Value::Text("hello".into());
2923        assert!(resolve_cbor_path(&value, &["key"]).is_none());
2924    }
2925
2926    #[test]
2927    fn resolve_cbor_path_map_missing_key() {
2928        let value = ciborium::Value::Map(vec![(
2929            ciborium::Value::Text("a".into()),
2930            ciborium::Value::Text("b".into()),
2931        )]);
2932        assert!(resolve_cbor_path(&value, &["missing"]).is_none());
2933    }
2934
2935    // ── lookup_string_key ──
2936
2937    #[test]
2938    fn lookup_string_key_version() {
2939        let meta = make_meta(vec![], BTreeMap::new());
2940        assert_eq!(lookup_string_key(&meta, "version"), Some("3".into()));
2941    }
2942
2943    #[test]
2944    fn lookup_string_key_empty() {
2945        let meta = make_meta(vec![], BTreeMap::new());
2946        assert!(lookup_string_key(&meta, "").is_none());
2947    }
2948
2949    #[test]
2950    fn lookup_string_key_integer_value() {
2951        let mut entry = BTreeMap::new();
2952        entry.insert("count".into(), ciborium::Value::Integer(42.into()));
2953        let meta = make_meta(vec![entry], BTreeMap::new());
2954        assert_eq!(lookup_string_key(&meta, "count"), Some("42".into()));
2955    }
2956
2957    #[test]
2958    fn lookup_string_key_float_value() {
2959        let mut extra = BTreeMap::new();
2960        extra.insert("temperature".into(), ciborium::Value::Float(98.6));
2961        let meta = make_meta(vec![], extra);
2962        assert_eq!(lookup_string_key(&meta, "temperature"), Some("98.6".into()));
2963    }
2964
2965    #[test]
2966    fn lookup_string_key_bool_value() {
2967        let mut extra = BTreeMap::new();
2968        extra.insert("flag".into(), ciborium::Value::Bool(true));
2969        let meta = make_meta(vec![], extra);
2970        assert_eq!(lookup_string_key(&meta, "flag"), Some("true".into()));
2971    }
2972
2973    #[test]
2974    fn lookup_string_key_null_returns_none() {
2975        let mut extra = BTreeMap::new();
2976        extra.insert("nothing".into(), ciborium::Value::Null);
2977        let meta = make_meta(vec![], extra);
2978        // Null is not a string/int/float/bool, so returns None
2979        assert!(lookup_string_key(&meta, "nothing").is_none());
2980    }
2981
2982    // ── lookup_int_key ──
2983
2984    #[test]
2985    fn lookup_int_key_version() {
2986        let meta = make_meta(vec![], BTreeMap::new());
2987        assert_eq!(lookup_int_key(&meta, "version"), Some(3));
2988    }
2989
2990    #[test]
2991    fn lookup_int_key_non_integer() {
2992        let mut extra = BTreeMap::new();
2993        extra.insert("str".into(), ciborium::Value::Text("not_int".into()));
2994        let meta = make_meta(vec![], extra);
2995        assert!(lookup_int_key(&meta, "str").is_none());
2996    }
2997
2998    // ── lookup_float_key ──
2999
3000    #[test]
3001    fn lookup_float_key_float() {
3002        let mut extra = BTreeMap::new();
3003        extra.insert("val".into(), ciborium::Value::Float(98.6));
3004        let meta = make_meta(vec![], extra);
3005        assert_eq!(lookup_float_key(&meta, "val"), Some(98.6));
3006    }
3007
3008    #[test]
3009    fn lookup_float_key_integer_coercion() {
3010        let mut extra = BTreeMap::new();
3011        extra.insert("count".into(), ciborium::Value::Integer(42.into()));
3012        let meta = make_meta(vec![], extra);
3013        assert_eq!(lookup_float_key(&meta, "count"), Some(42.0));
3014    }
3015
3016    #[test]
3017    fn lookup_float_key_non_numeric() {
3018        let mut extra = BTreeMap::new();
3019        extra.insert("str".into(), ciborium::Value::Text("hello".into()));
3020        let meta = make_meta(vec![], extra);
3021        assert!(lookup_float_key(&meta, "str").is_none());
3022    }
3023
3024    // ── parse_encode_json ──
3025
3026    #[test]
3027    fn parse_encode_json_with_base() {
3028        let json = r#"{"version":3,"base":[{"mars":{"param":"2t"}}],"descriptors":[]}"#;
3029        let (gm, descs) = parse_encode_json(json).unwrap();
3030        assert_eq!(gm.base.len(), 1);
3031        assert!(gm.base[0].contains_key("mars"));
3032        assert!(descs.is_empty());
3033    }
3034
3035    #[test]
3036    fn parse_encode_json_legacy_version_routed_to_extra() {
3037        // A caller-supplied legacy top-level `"version"` lands in
3038        // `_extra_["version"]` on decode — matching the Python /
3039        // TypeScript / Rust-core contract.  See Copilot review on
3040        // PR #80.
3041        let json = r#"{"version":3,"descriptors":[]}"#;
3042        let (gm, _) = parse_encode_json(json).unwrap();
3043        assert_eq!(
3044            gm.extra.get("version"),
3045            Some(&ciborium::Value::Integer(3u64.into())),
3046            "legacy JSON `version` must round-trip via `_extra_`"
3047        );
3048    }
3049
3050    #[test]
3051    fn parse_encode_json_free_form_top_level_routed_to_extra() {
3052        // Parity with the Rust core + Python: unknown JSON top-level
3053        // keys flow into `_extra_`.
3054        let json = r#"{"source":"test","count":42,"descriptors":[]}"#;
3055        let (gm, _) = parse_encode_json(json).unwrap();
3056        assert_eq!(
3057            gm.extra.get("source"),
3058            Some(&ciborium::Value::Text("test".to_string()))
3059        );
3060        assert_eq!(
3061            gm.extra.get("count"),
3062            Some(&ciborium::Value::Integer(42u64.into()))
3063        );
3064    }
3065
3066    #[test]
3067    fn parse_encode_json_explicit_extra_unpacked() {
3068        // An explicit `"_extra_"` section at the top level of the FFI
3069        // JSON must be unpacked into `GlobalMetadata.extra` — matching
3070        // the Python / TypeScript / Rust-core contract.  If `_extra_`
3071        // were treated as just another free-form key, a caller doing
3072        // `{"_extra_": {"foo": "bar"}}` would end up with a nested
3073        // `_extra_._extra_.foo` on the wire — clearly wrong.
3074        let json = r#"{"_extra_":{"foo":"bar","count":7},"descriptors":[]}"#;
3075        let (gm, _) = parse_encode_json(json).unwrap();
3076        assert_eq!(
3077            gm.extra.get("foo"),
3078            Some(&ciborium::Value::Text("bar".to_string())),
3079            "explicit `_extra_.foo` must surface at the top level of `extra`"
3080        );
3081        assert_eq!(
3082            gm.extra.get("count"),
3083            Some(&ciborium::Value::Integer(7u64.into())),
3084            "explicit `_extra_.count` must surface at the top level of `extra`"
3085        );
3086        assert!(
3087            !gm.extra.contains_key("_extra_"),
3088            "there must be no nested `_extra_` key inside `extra`"
3089        );
3090    }
3091
3092    #[test]
3093    fn parse_encode_json_explicit_extra_beats_free_form() {
3094        // `explicit beats implicit`: when both an explicit `_extra_.X`
3095        // and a free-form top-level `X` are supplied, the explicit
3096        // entry wins.  Matches the Rust core + Python / TS behaviour.
3097        let json = r#"{"version":99,"_extra_":{"version":1},"descriptors":[]}"#;
3098        let (gm, _) = parse_encode_json(json).unwrap();
3099        assert_eq!(
3100            gm.extra.get("version"),
3101            Some(&ciborium::Value::Integer(1u64.into())),
3102            "explicit _extra_.version must win over top-level version"
3103        );
3104    }
3105
3106    #[test]
3107    fn parse_encode_json_without_base() {
3108        let json = r#"{"version":3,"descriptors":[]}"#;
3109        let (gm, _) = parse_encode_json(json).unwrap();
3110        assert!(gm.base.is_empty());
3111    }
3112
3113    #[test]
3114    fn parse_encode_json_reserved_in_base_rejected() {
3115        let json = r#"{"version":3,"base":[{"_reserved_":{"tensor":{}}}],"descriptors":[]}"#;
3116        let result = parse_encode_json(json);
3117        assert!(result.is_err());
3118        assert!(result.unwrap_err().contains("_reserved_"));
3119    }
3120
3121    #[test]
3122    fn parse_encode_json_extra_keys() {
3123        let json = r#"{"version":3,"descriptors":[],"source":"test","count":42}"#;
3124        let (gm, _) = parse_encode_json(json).unwrap();
3125        assert!(gm.extra.contains_key("source"));
3126        assert!(gm.extra.contains_key("count"));
3127    }
3128
3129    // ── parse_streaming_metadata_json ──
3130
3131    #[test]
3132    fn parse_streaming_json_with_base() {
3133        let json = r#"{"version":3,"base":[{"mars":{"param":"2t"}}]}"#;
3134        let gm = parse_streaming_metadata_json(json).unwrap();
3135        assert_eq!(gm.base.len(), 1);
3136    }
3137
3138    #[test]
3139    fn parse_streaming_json_reserved_rejected() {
3140        let json = r#"{"version":3,"base":[{"_reserved_":{"tensor":{}}}]}"#;
3141        let result = parse_streaming_metadata_json(json);
3142        assert!(result.is_err());
3143        assert!(result.unwrap_err().contains("_reserved_"));
3144    }
3145
3146    #[test]
3147    fn parse_streaming_json_no_base() {
3148        let json = r#"{"version":3,"source":"stream"}"#;
3149        let gm = parse_streaming_metadata_json(json).unwrap();
3150        assert!(gm.base.is_empty());
3151        assert!(gm.extra.contains_key("source"));
3152    }
3153
3154    #[test]
3155    fn parse_streaming_json_explicit_extra_unpacked() {
3156        // Streaming path must honour the same `_extra_` unpacking as
3157        // `parse_encode_json`.
3158        let json = r#"{"_extra_":{"foo":"bar"}}"#;
3159        let gm = parse_streaming_metadata_json(json).unwrap();
3160        assert_eq!(
3161            gm.extra.get("foo"),
3162            Some(&ciborium::Value::Text("bar".to_string()))
3163        );
3164        assert!(!gm.extra.contains_key("_extra_"));
3165    }
3166
3167    #[test]
3168    fn parse_streaming_json_explicit_extra_beats_free_form() {
3169        // `explicit beats implicit` on the streaming path too.
3170        let json = r#"{"version":99,"_extra_":{"version":1}}"#;
3171        let gm = parse_streaming_metadata_json(json).unwrap();
3172        assert_eq!(
3173            gm.extra.get("version"),
3174            Some(&ciborium::Value::Integer(1u64.into()))
3175        );
3176    }
3177
3178    #[test]
3179    fn parse_streaming_json_invalid_json() {
3180        assert!(parse_streaming_metadata_json("not json").is_err());
3181    }
3182
3183    #[test]
3184    fn parse_encode_json_rejects_both_extra_aliases() {
3185        // `_extra_` and `extra` are aliases for the same concept;
3186        // supplying both is ambiguous and rejected by the helper.
3187        let json = r#"{"_extra_":{"a":1},"extra":{"b":2},"descriptors":[]}"#;
3188        let err = parse_encode_json(json).unwrap_err();
3189        assert!(
3190            err.contains("both '_extra_' and 'extra'"),
3191            "unexpected error: {err}"
3192        );
3193    }
3194
3195    #[test]
3196    fn parse_encode_json_rejects_non_object_extra() {
3197        // `_extra_` must be a JSON object.  A scalar is a caller
3198        // error and surfaces a clear message.
3199        let json = r#"{"_extra_":42,"descriptors":[]}"#;
3200        let err = parse_encode_json(json).unwrap_err();
3201        assert!(
3202            err.contains("'_extra_' must be a JSON object"),
3203            "unexpected error: {err}"
3204        );
3205    }
3206
3207    #[test]
3208    fn parse_encode_json_invalid_json() {
3209        assert!(parse_encode_json("not json").is_err());
3210    }
3211
3212    // ── json_to_cbor ──
3213
3214    #[test]
3215    fn json_to_cbor_null() {
3216        assert_eq!(json_to_cbor(serde_json::Value::Null), ciborium::Value::Null);
3217    }
3218
3219    #[test]
3220    fn json_to_cbor_bool() {
3221        assert_eq!(
3222            json_to_cbor(serde_json::Value::Bool(true)),
3223            ciborium::Value::Bool(true)
3224        );
3225    }
3226
3227    #[test]
3228    fn json_to_cbor_integer() {
3229        let val = serde_json::json!(42);
3230        let cbor = json_to_cbor(val);
3231        assert!(matches!(cbor, ciborium::Value::Integer(_)));
3232    }
3233
3234    #[test]
3235    fn json_to_cbor_float() {
3236        let val = serde_json::json!(98.6);
3237        let cbor = json_to_cbor(val);
3238        assert!(matches!(cbor, ciborium::Value::Float(_)));
3239    }
3240
3241    #[test]
3242    fn json_to_cbor_string() {
3243        let val = serde_json::json!("hello");
3244        let cbor = json_to_cbor(val);
3245        assert!(matches!(cbor, ciborium::Value::Text(s) if s == "hello"));
3246    }
3247
3248    #[test]
3249    fn json_to_cbor_array() {
3250        let val = serde_json::json!([1, 2, 3]);
3251        let cbor = json_to_cbor(val);
3252        assert!(matches!(cbor, ciborium::Value::Array(_)));
3253    }
3254
3255    #[test]
3256    fn json_to_cbor_object() {
3257        let val = serde_json::json!({"key": "value"});
3258        let cbor = json_to_cbor(val);
3259        assert!(matches!(cbor, ciborium::Value::Map(_)));
3260    }
3261
3262    #[test]
3263    fn json_to_cbor_u64_fallback_to_float() {
3264        // A number that is not i64 but is u64 → falls back to float
3265        // (JSON numbers outside i64 range)
3266        let val = serde_json::json!(18446744073709551615u64);
3267        let cbor = json_to_cbor(val);
3268        // This should be either Integer or Float depending on serde_json parsing
3269        assert!(!matches!(cbor, ciborium::Value::Null));
3270    }
3271
3272    // ── resolve helpers ──
3273
3274    #[test]
3275    fn resolve_in_btree_skip_reserved_blocks_reserved() {
3276        let mut map = BTreeMap::new();
3277        map.insert("_reserved_".into(), ciborium::Value::Text("secret".into()));
3278        assert!(resolve_in_btree_skip_reserved(&map, &["_reserved_"]).is_none());
3279    }
3280
3281    #[test]
3282    fn resolve_in_btree_empty_parts() {
3283        let map = BTreeMap::new();
3284        assert!(resolve_in_btree(&map, &[]).is_none());
3285    }
3286
3287    #[test]
3288    fn resolve_in_btree_skip_reserved_empty_parts() {
3289        let map = BTreeMap::new();
3290        assert!(resolve_in_btree_skip_reserved(&map, &[]).is_none());
3291    }
3292
3293    // ── validate FFI ──
3294
3295    #[test]
3296    fn parse_validate_options_default() {
3297        let opts = match super::parse_validate_options(ptr::null(), 0) {
3298            Ok(opts) => opts,
3299            Err((_code, msg)) => panic!("expected default options, got error: {msg}"),
3300        };
3301        assert_eq!(opts.max_level, ValidationLevel::Integrity);
3302        assert!(!opts.check_canonical);
3303        assert!(!opts.checksum_only);
3304    }
3305
3306    #[test]
3307    fn parse_validate_options_quick() {
3308        let level = CString::new("quick").unwrap();
3309        let opts = match super::parse_validate_options(level.as_ptr(), 0) {
3310            Ok(opts) => opts,
3311            Err((_code, msg)) => panic!("expected quick options, got error: {msg}"),
3312        };
3313        assert_eq!(opts.max_level, ValidationLevel::Structure);
3314    }
3315
3316    #[test]
3317    fn parse_validate_options_full_canonical() {
3318        let level = CString::new("full").unwrap();
3319        let opts = match super::parse_validate_options(level.as_ptr(), 1) {
3320            Ok(opts) => opts,
3321            Err((_code, msg)) => panic!("expected full options, got error: {msg}"),
3322        };
3323        assert_eq!(opts.max_level, ValidationLevel::Fidelity);
3324        assert!(opts.check_canonical);
3325    }
3326
3327    #[test]
3328    fn parse_validate_options_unknown_level() {
3329        let level = CString::new("bogus").unwrap();
3330        let result = super::parse_validate_options(level.as_ptr(), 0);
3331        assert!(result.is_err());
3332    }
3333
3334    #[test]
3335    fn parse_validate_options_checksum() {
3336        let level = CString::new("checksum").unwrap();
3337        let opts = match super::parse_validate_options(level.as_ptr(), 0) {
3338            Ok(opts) => opts,
3339            Err((_code, msg)) => panic!("expected checksum options, got error: {msg}"),
3340        };
3341        assert_eq!(opts.max_level, ValidationLevel::Integrity);
3342        assert!(opts.checksum_only);
3343    }
3344
3345    // ── tgm_validate end-to-end ──
3346
3347    fn encode_test_message() -> Vec<u8> {
3348        let meta = GlobalMetadata::default();
3349        let desc = DataObjectDescriptor {
3350            obj_type: "ntensor".to_string(),
3351            ndim: 1,
3352            shape: vec![4],
3353            strides: vec![1],
3354            dtype: tensogram::Dtype::Float32,
3355            byte_order: tensogram::ByteOrder::native(),
3356            encoding: "none".to_string(),
3357            filter: "none".to_string(),
3358            compression: "none".to_string(),
3359            params: BTreeMap::new(),
3360            masks: None,
3361        };
3362        let data: Vec<u8> = [1.0f32, 2.0, 3.0, 4.0]
3363            .iter()
3364            .flat_map(|v| v.to_ne_bytes())
3365            .collect();
3366        tensogram::encode(&meta, &[(&desc, data.as_slice())], &Default::default()).unwrap()
3367    }
3368
3369    #[test]
3370    fn tgm_validate_valid_message() {
3371        let msg = encode_test_message();
3372        let mut out = super::TgmBytes {
3373            data: ptr::null_mut(),
3374            len: 0,
3375        };
3376        let err = super::tgm_validate(msg.as_ptr(), msg.len(), ptr::null(), 0, &mut out);
3377        assert!(matches!(err, super::TgmError::Ok));
3378        assert!(!out.data.is_null());
3379        assert!(out.len > 0);
3380        let json_str =
3381            unsafe { std::str::from_utf8(std::slice::from_raw_parts(out.data, out.len)).unwrap() };
3382        assert!(json_str.contains("\"issues\":[]"));
3383        assert!(json_str.contains("\"object_count\":1"));
3384        super::tgm_bytes_free(out);
3385    }
3386
3387    #[test]
3388    fn tgm_validate_empty_buffer() {
3389        let mut out = super::TgmBytes {
3390            data: ptr::null_mut(),
3391            len: 0,
3392        };
3393        let err = super::tgm_validate(ptr::null(), 0, ptr::null(), 0, &mut out);
3394        assert!(matches!(err, super::TgmError::Ok));
3395        let json_str =
3396            unsafe { std::str::from_utf8(std::slice::from_raw_parts(out.data, out.len)).unwrap() };
3397        assert!(json_str.contains("\"buffer_too_short\""));
3398        super::tgm_bytes_free(out);
3399    }
3400
3401    #[test]
3402    fn tgm_validate_invalid_level() {
3403        let msg = encode_test_message();
3404        let level = CString::new("bogus").unwrap();
3405        let mut out = super::TgmBytes {
3406            data: ptr::null_mut(),
3407            len: 0,
3408        };
3409        let err = super::tgm_validate(msg.as_ptr(), msg.len(), level.as_ptr(), 0, &mut out);
3410        assert!(matches!(err, super::TgmError::InvalidArg));
3411    }
3412
3413    #[test]
3414    fn tgm_validate_null_out() {
3415        let msg = encode_test_message();
3416        let err = super::tgm_validate(msg.as_ptr(), msg.len(), ptr::null(), 0, ptr::null_mut());
3417        assert!(matches!(err, super::TgmError::InvalidArg));
3418    }
3419
3420    #[test]
3421    fn tgm_validate_file_nonexistent() {
3422        let path = CString::new("/nonexistent/path/to/file.tgm").unwrap();
3423        let mut out = super::TgmBytes {
3424            data: ptr::null_mut(),
3425            len: 0,
3426        };
3427        let err = super::tgm_validate_file(path.as_ptr(), ptr::null(), 0, &mut out);
3428        assert!(matches!(err, super::TgmError::Io));
3429    }
3430
3431    #[test]
3432    fn tgm_validate_file_null_out() {
3433        let path = CString::new("/tmp/dummy.tgm").unwrap();
3434        let err = super::tgm_validate_file(path.as_ptr(), ptr::null(), 0, ptr::null_mut());
3435        assert!(matches!(err, super::TgmError::InvalidArg));
3436    }
3437
3438    #[test]
3439    fn tgm_validate_file_invalid_level() {
3440        let path = CString::new("/tmp/dummy.tgm").unwrap();
3441        let level = CString::new("bogus").unwrap();
3442        let mut out = super::TgmBytes {
3443            data: ptr::null_mut(),
3444            len: 0,
3445        };
3446        let err = super::tgm_validate_file(path.as_ptr(), level.as_ptr(), 0, &mut out);
3447        assert!(matches!(err, super::TgmError::InvalidArg));
3448    }
3449
3450    // =====================================================================
3451    // FFI round-trip tests — exercise #[no_mangle] extern "C" functions
3452    // =====================================================================
3453
3454    /// Helper: build a JSON metadata string and raw data for a single float32
3455    /// tensor, encode via `tgm_encode`, and return the encoded bytes.
3456    fn ffi_encode_single_f32_tensor(values: &[f32], extra_json: &str) -> Vec<u8> {
3457        let shape_str = format!("[{}]", values.len());
3458        let json = format!(
3459            r#"{{"version":3,"descriptors":[{{"type":"ntensor","ndim":1,"shape":{shape},"strides":[1],"dtype":"float32","byte_order":"{bo}","encoding":"none","filter":"none","compression":"none"}}]{extra}}}"#,
3460            shape = shape_str,
3461            bo = if cfg!(target_endian = "little") {
3462                "little"
3463            } else {
3464                "big"
3465            },
3466            extra = if extra_json.is_empty() {
3467                String::new()
3468            } else {
3469                format!(",{extra_json}")
3470            },
3471        );
3472
3473        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
3474        let c_json = CString::new(json).unwrap();
3475        let data_ptr: *const u8 = data.as_ptr();
3476        let data_len: usize = data.len();
3477
3478        let mut out = super::TgmBytes {
3479            data: ptr::null_mut(),
3480            len: 0,
3481        };
3482
3483        let err = super::tgm_encode(
3484            c_json.as_ptr(),
3485            &data_ptr as *const *const u8,
3486            &data_len as *const usize,
3487            1,
3488            ptr::null(), // no hash
3489            0,           // threads
3490            &mut out,
3491        );
3492        assert!(matches!(err, super::TgmError::Ok), "tgm_encode failed");
3493        assert!(!out.data.is_null());
3494        assert!(out.len > 0);
3495
3496        let encoded = unsafe { slice::from_raw_parts(out.data, out.len) }.to_vec();
3497        super::tgm_bytes_free(out);
3498        encoded
3499    }
3500
3501    /// Helper: encode with hash enabled.
3502    fn ffi_encode_with_hash(values: &[f32]) -> Vec<u8> {
3503        let shape_str = format!("[{}]", values.len());
3504        let json = format!(
3505            r#"{{"version":3,"descriptors":[{{"type":"ntensor","ndim":1,"shape":{shape},"strides":[1],"dtype":"float32","byte_order":"{bo}","encoding":"none","filter":"none","compression":"none"}}]}}"#,
3506            shape = shape_str,
3507            bo = if cfg!(target_endian = "little") {
3508                "little"
3509            } else {
3510                "big"
3511            },
3512        );
3513
3514        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
3515        let c_json = CString::new(json).unwrap();
3516        let hash_algo = CString::new("xxh3").unwrap();
3517        let data_ptr: *const u8 = data.as_ptr();
3518        let data_len: usize = data.len();
3519
3520        let mut out = super::TgmBytes {
3521            data: ptr::null_mut(),
3522            len: 0,
3523        };
3524
3525        let err = super::tgm_encode(
3526            c_json.as_ptr(),
3527            &data_ptr as *const *const u8,
3528            &data_len as *const usize,
3529            1,
3530            hash_algo.as_ptr(),
3531            0,
3532            &mut out,
3533        );
3534        assert!(
3535            matches!(err, super::TgmError::Ok),
3536            "tgm_encode with hash failed"
3537        );
3538
3539        let encoded = unsafe { slice::from_raw_parts(out.data, out.len) }.to_vec();
3540        super::tgm_bytes_free(out);
3541        encoded
3542    }
3543
3544    // ── tgm_encode / tgm_decode round-trip ──
3545
3546    #[test]
3547    fn ffi_encode_decode_round_trip() {
3548        let values = [1.0f32, 2.0, 3.0, 4.0];
3549        let encoded = ffi_encode_single_f32_tensor(&values, "");
3550
3551        // Decode
3552        let mut msg: *mut super::TgmMessage = ptr::null_mut();
3553        let err = super::tgm_decode(
3554            encoded.as_ptr(),
3555            encoded.len(),
3556            0, // no native byte order rewrite
3557            0, // threads
3558            0, // verify_hash
3559            &mut msg,
3560        );
3561        assert!(matches!(err, super::TgmError::Ok));
3562        assert!(!msg.is_null());
3563
3564        // Message-level accessors
3565        assert_eq!(super::tgm_message_version(msg), 3);
3566        assert_eq!(super::tgm_message_num_objects(msg), 1);
3567        assert_eq!(super::tgm_message_num_decoded(msg), 1);
3568
3569        // Object-level accessors
3570        assert_eq!(super::tgm_object_ndim(msg, 0), 1);
3571
3572        let shape_ptr = super::tgm_object_shape(msg, 0);
3573        assert!(!shape_ptr.is_null());
3574        assert_eq!(unsafe { *shape_ptr }, 4);
3575
3576        let strides_ptr = super::tgm_object_strides(msg, 0);
3577        assert!(!strides_ptr.is_null());
3578        assert_eq!(unsafe { *strides_ptr }, 1);
3579
3580        // dtype string
3581        let dtype_ptr = super::tgm_object_dtype(msg, 0);
3582        assert!(!dtype_ptr.is_null());
3583        let dtype_str = unsafe { CStr::from_ptr(dtype_ptr) }.to_str().unwrap();
3584        assert_eq!(dtype_str, "float32");
3585
3586        // type string
3587        let type_ptr = super::tgm_object_type(msg, 0);
3588        assert!(!type_ptr.is_null());
3589        let type_str = unsafe { CStr::from_ptr(type_ptr) }.to_str().unwrap();
3590        assert_eq!(type_str, "ntensor");
3591
3592        // byte_order string
3593        let bo_ptr = super::tgm_object_byte_order(msg, 0);
3594        assert!(!bo_ptr.is_null());
3595        let bo_str = unsafe { CStr::from_ptr(bo_ptr) }.to_str().unwrap();
3596        assert!(bo_str == "little" || bo_str == "big");
3597
3598        // filter string
3599        let filter_ptr = super::tgm_object_filter(msg, 0);
3600        assert!(!filter_ptr.is_null());
3601        let filter_str = unsafe { CStr::from_ptr(filter_ptr) }.to_str().unwrap();
3602        assert_eq!(filter_str, "none");
3603
3604        // compression string
3605        let comp_ptr = super::tgm_object_compression(msg, 0);
3606        assert!(!comp_ptr.is_null());
3607        let comp_str = unsafe { CStr::from_ptr(comp_ptr) }.to_str().unwrap();
3608        assert_eq!(comp_str, "none");
3609
3610        // encoding string
3611        let enc_ptr = super::tgm_payload_encoding(msg, 0);
3612        assert!(!enc_ptr.is_null());
3613        let enc_str = unsafe { CStr::from_ptr(enc_ptr) }.to_str().unwrap();
3614        assert_eq!(enc_str, "none");
3615
3616        // decoded data
3617        let mut data_len: usize = 0;
3618        let data_ptr = super::tgm_object_data(msg, 0, &mut data_len);
3619        assert!(!data_ptr.is_null());
3620        assert_eq!(data_len, 16); // 4 × 4 bytes
3621
3622        let decoded_bytes = unsafe { slice::from_raw_parts(data_ptr, data_len) };
3623        let decoded_values: Vec<f32> = decoded_bytes
3624            .chunks_exact(4)
3625            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
3626            .collect();
3627        assert_eq!(decoded_values, values);
3628
3629        super::tgm_message_free(msg);
3630    }
3631
3632    #[test]
3633    fn ffi_encode_decode_with_hash() {
3634        let values = [10.0f32, 20.0, 30.0];
3635        let encoded = ffi_encode_with_hash(&values);
3636
3637        let mut msg: *mut super::TgmMessage = ptr::null_mut();
3638        let err = super::tgm_decode(
3639            encoded.as_ptr(),
3640            encoded.len(),
3641            0,
3642            0, // threads
3643            0, // verify_hash
3644            &mut msg,
3645        );
3646        assert!(matches!(err, super::TgmError::Ok));
3647
3648        // Hash should be present
3649        assert_eq!(super::tgm_payload_has_hash(msg, 0), 1);
3650
3651        let ht_ptr = super::tgm_object_hash_type(msg, 0);
3652        assert!(!ht_ptr.is_null());
3653        let ht_str = unsafe { CStr::from_ptr(ht_ptr) }.to_str().unwrap();
3654        assert_eq!(ht_str, "xxh3");
3655
3656        let hv_ptr = super::tgm_object_hash_value(msg, 0);
3657        assert!(!hv_ptr.is_null());
3658        let hv_str = unsafe { CStr::from_ptr(hv_ptr) }.to_str().unwrap();
3659        assert!(!hv_str.is_empty());
3660
3661        super::tgm_message_free(msg);
3662    }
3663
3664    #[test]
3665    fn ffi_encode_decode_no_hash() {
3666        let values = [5.0f32];
3667        let encoded = ffi_encode_single_f32_tensor(&values, "");
3668
3669        let mut msg: *mut super::TgmMessage = ptr::null_mut();
3670        let err = super::tgm_decode(encoded.as_ptr(), encoded.len(), 0, 0, 0, &mut msg);
3671        assert!(matches!(err, super::TgmError::Ok));
3672
3673        assert_eq!(super::tgm_payload_has_hash(msg, 0), 0);
3674        assert!(super::tgm_object_hash_type(msg, 0).is_null());
3675        assert!(super::tgm_object_hash_value(msg, 0).is_null());
3676
3677        super::tgm_message_free(msg);
3678    }
3679
3680    // ── verify_hash on the FFI surface ───────────────────────────────
3681
3682    #[test]
3683    fn ffi_decode_verify_hash_succeeds_on_hashed_message() {
3684        // Cell B: hashed message + verify_hash=1 → Ok.
3685        let encoded = ffi_encode_with_hash(&[1.0f32, 2.0]);
3686        let mut msg: *mut super::TgmMessage = ptr::null_mut();
3687        let err = super::tgm_decode(
3688            encoded.as_ptr(),
3689            encoded.len(),
3690            0,
3691            0,
3692            1, // verify_hash
3693            &mut msg,
3694        );
3695        assert!(matches!(err, super::TgmError::Ok));
3696        assert!(!msg.is_null());
3697        super::tgm_message_free(msg);
3698    }
3699
3700    #[test]
3701    fn ffi_decode_verify_hash_returns_missing_hash_on_unhashed_message() {
3702        // Cell C: unhashed message + verify_hash=1 → MissingHash.
3703        let encoded = ffi_encode_single_f32_tensor(&[5.0f32], "");
3704        let mut msg: *mut super::TgmMessage = ptr::null_mut();
3705        let err = super::tgm_decode(
3706            encoded.as_ptr(),
3707            encoded.len(),
3708            0,
3709            0,
3710            1, // verify_hash
3711            &mut msg,
3712        );
3713        assert!(
3714            matches!(err, super::TgmError::MissingHash),
3715            "expected MissingHash, got error code {}",
3716            err as i32
3717        );
3718        let last = unsafe { CStr::from_ptr(super::tgm_last_error()) }
3719            .to_str()
3720            .unwrap();
3721        assert!(
3722            last.contains("object 0"),
3723            "last error should name the offending object: {last}"
3724        );
3725        // No message handle was returned — nothing to free.
3726    }
3727
3728    #[test]
3729    fn ffi_decode_verify_hash_returns_hash_mismatch_on_tampered_slot() {
3730        // Cell D: hashed message with a flipped inline-hash-slot
3731        // byte + verify_hash=1 → HashMismatch.  Tampering the
3732        // slot (rather than the body) keeps the rest of the frame
3733        // structurally valid so the CBOR descriptor parses
3734        // cleanly — only the inline-hash check fires.  See
3735        // `decode_verify_hash.rs` (Rust core) for the cell E
3736        // variant where the payload itself is tampered.
3737        let mut encoded = ffi_encode_with_hash(&[10.0f32, 20.0, 30.0]);
3738        // Locate the message footer's preceding object frame and
3739        // flip a byte of the 8-byte hash slot, which lives at
3740        // `frame_end - 12` for every frame.  Walking from the
3741        // preamble end (24) we find the first NTensorFrame.
3742        let frame_start = {
3743            let mut pos = 24usize;
3744            loop {
3745                assert!(pos + 16 <= encoded.len(), "frame not found");
3746                if &encoded[pos..pos + 2] == b"FR"
3747                    && tensogram::wire::FrameHeader::read_from(&encoded[pos..])
3748                        .map(|fh| fh.frame_type.is_data_object())
3749                        .unwrap_or(false)
3750                {
3751                    break pos;
3752                }
3753                pos += 1;
3754            }
3755        };
3756        let fh = tensogram::wire::FrameHeader::read_from(&encoded[frame_start..]).unwrap();
3757        let frame_end = frame_start + fh.total_length as usize;
3758        let slot_byte = frame_end - 12; // first byte of the 8-byte slot
3759        encoded[slot_byte] ^= 0xFF;
3760
3761        let mut msg: *mut super::TgmMessage = ptr::null_mut();
3762        let err = super::tgm_decode(
3763            encoded.as_ptr(),
3764            encoded.len(),
3765            0,
3766            0,
3767            1, // verify_hash
3768            &mut msg,
3769        );
3770        assert!(
3771            matches!(err, super::TgmError::HashMismatch),
3772            "expected HashMismatch, got error code {}",
3773            err as i32
3774        );
3775        let last = unsafe { CStr::from_ptr(super::tgm_last_error()) }
3776            .to_str()
3777            .unwrap();
3778        assert!(
3779            last.contains("object 0"),
3780            "last error should name the offending object: {last}"
3781        );
3782    }
3783
3784    #[test]
3785    fn ffi_decode_verify_hash_off_silently_decodes_unhashed_message() {
3786        // Cell A complement — no verify, unhashed message decodes
3787        // cleanly (the existing `ffi_encode_decode_no_hash` test
3788        // covers this implicitly; here we add an explicit
3789        // verify_hash=0 assertion to pin the default).
3790        let encoded = ffi_encode_single_f32_tensor(&[5.0f32], "");
3791        let mut msg: *mut super::TgmMessage = ptr::null_mut();
3792        let err = super::tgm_decode(
3793            encoded.as_ptr(),
3794            encoded.len(),
3795            0,
3796            0,
3797            0, // verify_hash off
3798            &mut msg,
3799        );
3800        assert!(matches!(err, super::TgmError::Ok));
3801        super::tgm_message_free(msg);
3802    }
3803
3804    #[test]
3805    fn ffi_decode_object_verify_hash_returns_missing_hash_on_unhashed() {
3806        // Cell C for tgm_decode_object.
3807        let encoded = ffi_encode_single_f32_tensor(&[5.0f32], "");
3808        let mut msg: *mut super::TgmMessage = ptr::null_mut();
3809        let err = super::tgm_decode_object(
3810            encoded.as_ptr(),
3811            encoded.len(),
3812            0,
3813            0,
3814            0,
3815            1, // verify_hash
3816            &mut msg,
3817        );
3818        assert!(
3819            matches!(err, super::TgmError::MissingHash),
3820            "expected MissingHash, got error code {}",
3821            err as i32
3822        );
3823    }
3824
3825    #[test]
3826    fn ffi_encode_with_extra_metadata() {
3827        let values = [1.0f32, 2.0];
3828        let encoded = ffi_encode_single_f32_tensor(&values, r#""source":"test_source","count":42"#);
3829
3830        let mut msg: *mut super::TgmMessage = ptr::null_mut();
3831        let err = super::tgm_decode(encoded.as_ptr(), encoded.len(), 0, 0, 0, &mut msg);
3832        assert!(matches!(err, super::TgmError::Ok));
3833
3834        // Extract metadata from decoded message
3835        let mut meta: *mut super::TgmMetadata = ptr::null_mut();
3836        let err = super::tgm_message_metadata(msg, &mut meta);
3837        assert!(matches!(err, super::TgmError::Ok));
3838
3839        let key = CString::new("source").unwrap();
3840        let val_ptr = super::tgm_metadata_get_string(meta, key.as_ptr());
3841        assert!(!val_ptr.is_null());
3842        let val_str = unsafe { CStr::from_ptr(val_ptr) }.to_str().unwrap();
3843        assert_eq!(val_str, "test_source");
3844
3845        let key_count = CString::new("count").unwrap();
3846        let val_int = super::tgm_metadata_get_int(meta, key_count.as_ptr(), -1);
3847        assert_eq!(val_int, 42);
3848
3849        super::tgm_metadata_free(meta);
3850        super::tgm_message_free(msg);
3851    }
3852
3853    // ── tgm_encode null/error paths ──
3854
3855    #[test]
3856    fn ffi_encode_null_json() {
3857        let mut out = super::TgmBytes {
3858            data: ptr::null_mut(),
3859            len: 0,
3860        };
3861        let err = super::tgm_encode(
3862            ptr::null(),
3863            ptr::null(),
3864            ptr::null(),
3865            0,
3866            ptr::null(),
3867            0,
3868            &mut out,
3869        );
3870        assert!(matches!(err, super::TgmError::InvalidArg));
3871    }
3872
3873    #[test]
3874    fn ffi_encode_null_out() {
3875        let json = CString::new(r#"{"version":3,"descriptors":[]}"#).unwrap();
3876        let err = super::tgm_encode(
3877            json.as_ptr(),
3878            ptr::null(),
3879            ptr::null(),
3880            0,
3881            ptr::null(),
3882            0,
3883            ptr::null_mut(),
3884        );
3885        assert!(matches!(err, super::TgmError::InvalidArg));
3886    }
3887
3888    #[test]
3889    fn ffi_encode_descriptor_count_mismatch() {
3890        // JSON says 0 descriptors, but num_objects = 1
3891        let json = CString::new(r#"{"version":3,"descriptors":[]}"#).unwrap();
3892        let data: [u8; 4] = [0; 4];
3893        let data_ptr: *const u8 = data.as_ptr();
3894        let data_len: usize = 4;
3895        let mut out = super::TgmBytes {
3896            data: ptr::null_mut(),
3897            len: 0,
3898        };
3899        let err = super::tgm_encode(
3900            json.as_ptr(),
3901            &data_ptr as *const *const u8,
3902            &data_len as *const usize,
3903            1, // mismatch!
3904            ptr::null(),
3905            0, // threads
3906            &mut out,
3907        );
3908        assert!(matches!(err, super::TgmError::InvalidArg));
3909    }
3910
3911    #[test]
3912    fn ffi_encode_invalid_json() {
3913        let json = CString::new("not valid json").unwrap();
3914        let mut out = super::TgmBytes {
3915            data: ptr::null_mut(),
3916            len: 0,
3917        };
3918        let err = super::tgm_encode(
3919            json.as_ptr(),
3920            ptr::null(),
3921            ptr::null(),
3922            0,
3923            ptr::null(),
3924            0,
3925            &mut out,
3926        );
3927        assert!(matches!(err, super::TgmError::Metadata));
3928    }
3929
3930    // ── tgm_decode null/error paths ──
3931
3932    #[test]
3933    fn ffi_decode_null_buf() {
3934        let mut msg: *mut super::TgmMessage = ptr::null_mut();
3935        let err = super::tgm_decode(ptr::null(), 0, 0, 0, 0, &mut msg);
3936        assert!(matches!(err, super::TgmError::InvalidArg));
3937    }
3938
3939    #[test]
3940    fn ffi_decode_null_out() {
3941        let data = [0u8; 10];
3942        let err = super::tgm_decode(data.as_ptr(), data.len(), 0, 0, 0, ptr::null_mut());
3943        assert!(matches!(err, super::TgmError::InvalidArg));
3944    }
3945
3946    #[test]
3947    fn ffi_decode_garbage_data() {
3948        let data = [0u8; 10];
3949        let mut msg: *mut super::TgmMessage = ptr::null_mut();
3950        let err = super::tgm_decode(data.as_ptr(), data.len(), 0, 0, 0, &mut msg);
3951        // Should fail with a framing or other error
3952        assert!(!matches!(err, super::TgmError::Ok));
3953    }
3954
3955    // ── tgm_decode_metadata round-trip ──
3956
3957    #[test]
3958    fn ffi_decode_metadata_round_trip() {
3959        let values = [1.0f32, 2.0];
3960        let encoded = ffi_encode_single_f32_tensor(&values, r#""source":"meta_test""#);
3961
3962        let mut meta: *mut super::TgmMetadata = ptr::null_mut();
3963        let err = super::tgm_decode_metadata(encoded.as_ptr(), encoded.len(), &mut meta);
3964        assert!(matches!(err, super::TgmError::Ok));
3965        assert!(!meta.is_null());
3966
3967        // Version
3968        assert_eq!(super::tgm_metadata_version(meta), 3);
3969
3970        // num_objects
3971        assert_eq!(super::tgm_metadata_num_objects(meta), 1);
3972
3973        // String lookup
3974        let key = CString::new("source").unwrap();
3975        let val_ptr = super::tgm_metadata_get_string(meta, key.as_ptr());
3976        assert!(!val_ptr.is_null());
3977        let val_str = unsafe { CStr::from_ptr(val_ptr) }.to_str().unwrap();
3978        assert_eq!(val_str, "meta_test");
3979
3980        // Missing key returns null
3981        let bad_key = CString::new("nonexistent").unwrap();
3982        assert!(super::tgm_metadata_get_string(meta, bad_key.as_ptr()).is_null());
3983
3984        // Int with default
3985        let bad_key2 = CString::new("missing_int").unwrap();
3986        assert_eq!(
3987            super::tgm_metadata_get_int(meta, bad_key2.as_ptr(), -999),
3988            -999
3989        );
3990
3991        // Float with default
3992        let bad_key3 = CString::new("missing_float").unwrap();
3993        let fval = super::tgm_metadata_get_float(meta, bad_key3.as_ptr(), 3.25);
3994        assert!((fval - 3.25).abs() < f64::EPSILON);
3995
3996        super::tgm_metadata_free(meta);
3997    }
3998
3999    #[test]
4000    fn ffi_decode_metadata_null_args() {
4001        let mut meta: *mut super::TgmMetadata = ptr::null_mut();
4002        let err = super::tgm_decode_metadata(ptr::null(), 0, &mut meta);
4003        assert!(matches!(err, super::TgmError::InvalidArg));
4004
4005        let data = [0u8; 10];
4006        let err = super::tgm_decode_metadata(data.as_ptr(), data.len(), ptr::null_mut());
4007        assert!(matches!(err, super::TgmError::InvalidArg));
4008    }
4009
4010    // ── tgm_metadata null pointer safety ──
4011
4012    #[test]
4013    fn ffi_metadata_accessors_null_handle() {
4014        assert_eq!(super::tgm_metadata_version(ptr::null()), 0);
4015        assert_eq!(super::tgm_metadata_num_objects(ptr::null()), 0);
4016        assert!(super::tgm_metadata_get_string(ptr::null(), ptr::null()).is_null());
4017        assert_eq!(
4018            super::tgm_metadata_get_int(ptr::null(), ptr::null(), -1),
4019            -1
4020        );
4021        assert_eq!(
4022            super::tgm_metadata_get_float(ptr::null(), ptr::null(), 1.5),
4023            1.5
4024        );
4025    }
4026
4027    #[test]
4028    fn ffi_metadata_get_string_null_key() {
4029        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
4030        let mut meta: *mut super::TgmMetadata = ptr::null_mut();
4031        let err = super::tgm_decode_metadata(encoded.as_ptr(), encoded.len(), &mut meta);
4032        assert!(matches!(err, super::TgmError::Ok));
4033
4034        assert!(super::tgm_metadata_get_string(meta, ptr::null()).is_null());
4035        assert_eq!(super::tgm_metadata_get_int(meta, ptr::null(), -1), -1);
4036        assert_eq!(super::tgm_metadata_get_float(meta, ptr::null(), 1.5), 1.5);
4037
4038        super::tgm_metadata_free(meta);
4039    }
4040
4041    #[test]
4042    fn ffi_metadata_get_version_via_string() {
4043        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
4044        let mut meta: *mut super::TgmMetadata = ptr::null_mut();
4045        let err = super::tgm_decode_metadata(encoded.as_ptr(), encoded.len(), &mut meta);
4046        assert!(matches!(err, super::TgmError::Ok));
4047
4048        let key = CString::new("version").unwrap();
4049        let val_ptr = super::tgm_metadata_get_string(meta, key.as_ptr());
4050        assert!(!val_ptr.is_null());
4051        let val_str = unsafe { CStr::from_ptr(val_ptr) }.to_str().unwrap();
4052        assert_eq!(val_str, "3");
4053
4054        let ival = super::tgm_metadata_get_int(meta, key.as_ptr(), -1);
4055        assert_eq!(ival, 3);
4056
4057        super::tgm_metadata_free(meta);
4058    }
4059
4060    #[test]
4061    fn ffi_metadata_get_float_value() {
4062        let values = [1.0f32];
4063        let encoded = ffi_encode_single_f32_tensor(&values, r#""temperature":98.6"#);
4064
4065        let mut meta: *mut super::TgmMetadata = ptr::null_mut();
4066        let err = super::tgm_decode_metadata(encoded.as_ptr(), encoded.len(), &mut meta);
4067        assert!(matches!(err, super::TgmError::Ok));
4068
4069        let key = CString::new("temperature").unwrap();
4070        let fval = super::tgm_metadata_get_float(meta, key.as_ptr(), 0.0);
4071        assert!((fval - 98.6).abs() < 0.01);
4072
4073        super::tgm_metadata_free(meta);
4074    }
4075
4076    // ── tgm_decode_object ──
4077
4078    #[test]
4079    fn ffi_decode_object_round_trip() {
4080        let values = [10.0f32, 20.0, 30.0, 40.0];
4081        let encoded = ffi_encode_single_f32_tensor(&values, "");
4082
4083        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4084        let err = super::tgm_decode_object(
4085            encoded.as_ptr(),
4086            encoded.len(),
4087            0, // index
4088            0, // native byte order
4089            0, // threads
4090            0, // verify_hash
4091            &mut msg,
4092        );
4093        assert!(matches!(err, super::TgmError::Ok));
4094        assert!(!msg.is_null());
4095
4096        // Single object in result
4097        assert_eq!(super::tgm_message_num_objects(msg), 1);
4098        assert_eq!(super::tgm_object_ndim(msg, 0), 1);
4099
4100        let mut data_len: usize = 0;
4101        let data_ptr = super::tgm_object_data(msg, 0, &mut data_len);
4102        assert!(!data_ptr.is_null());
4103        let decoded_bytes = unsafe { slice::from_raw_parts(data_ptr, data_len) };
4104        let decoded_values: Vec<f32> = decoded_bytes
4105            .chunks_exact(4)
4106            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
4107            .collect();
4108        assert_eq!(decoded_values, values);
4109
4110        super::tgm_message_free(msg);
4111    }
4112
4113    #[test]
4114    fn ffi_decode_object_out_of_range() {
4115        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
4116        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4117        let err = super::tgm_decode_object(
4118            encoded.as_ptr(),
4119            encoded.len(),
4120            999, // out of range
4121            0,
4122            0, // threads
4123            0, // verify_hash
4124            &mut msg,
4125        );
4126        assert!(!matches!(err, super::TgmError::Ok));
4127    }
4128
4129    #[test]
4130    fn ffi_decode_object_null_args() {
4131        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4132        let err = super::tgm_decode_object(ptr::null(), 0, 0, 0, 0, 0, &mut msg);
4133        assert!(matches!(err, super::TgmError::InvalidArg));
4134
4135        let data = [0u8; 10];
4136        let err = super::tgm_decode_object(data.as_ptr(), data.len(), 0, 0, 0, 0, ptr::null_mut());
4137        assert!(matches!(err, super::TgmError::InvalidArg));
4138    }
4139
4140    // ── tgm_message_metadata ──
4141
4142    #[test]
4143    fn ffi_message_metadata_null_args() {
4144        let err = super::tgm_message_metadata(ptr::null(), ptr::null_mut());
4145        assert!(matches!(err, super::TgmError::InvalidArg));
4146
4147        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
4148        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4149        let err = super::tgm_decode(encoded.as_ptr(), encoded.len(), 0, 0, 0, &mut msg);
4150        assert!(matches!(err, super::TgmError::Ok));
4151
4152        let err = super::tgm_message_metadata(msg, ptr::null_mut());
4153        assert!(matches!(err, super::TgmError::InvalidArg));
4154
4155        super::tgm_message_free(msg);
4156    }
4157
4158    // ── tgm_message accessors with null msg ──
4159
4160    #[test]
4161    fn ffi_message_accessors_null_handle() {
4162        assert_eq!(super::tgm_message_version(ptr::null()), 0);
4163        assert_eq!(super::tgm_message_num_objects(ptr::null()), 0);
4164        assert_eq!(super::tgm_message_num_decoded(ptr::null()), 0);
4165        assert_eq!(super::tgm_object_ndim(ptr::null(), 0), 0);
4166        assert!(super::tgm_object_shape(ptr::null(), 0).is_null());
4167        assert!(super::tgm_object_strides(ptr::null(), 0).is_null());
4168        assert!(super::tgm_object_dtype(ptr::null(), 0).is_null());
4169        assert!(super::tgm_object_type(ptr::null(), 0).is_null());
4170        assert!(super::tgm_object_byte_order(ptr::null(), 0).is_null());
4171        assert!(super::tgm_object_filter(ptr::null(), 0).is_null());
4172        assert!(super::tgm_object_compression(ptr::null(), 0).is_null());
4173        assert!(super::tgm_payload_encoding(ptr::null(), 0).is_null());
4174        assert_eq!(super::tgm_payload_has_hash(ptr::null(), 0), 0);
4175        assert!(super::tgm_object_hash_type(ptr::null(), 0).is_null());
4176        assert!(super::tgm_object_hash_value(ptr::null(), 0).is_null());
4177
4178        let mut data_len: usize = 99;
4179        let data_ptr = super::tgm_object_data(ptr::null(), 0, &mut data_len);
4180        assert!(data_ptr.is_null());
4181        assert_eq!(data_len, 0);
4182    }
4183
4184    // ── tgm_message accessors out-of-bounds index ──
4185
4186    #[test]
4187    fn ffi_message_accessors_out_of_bounds() {
4188        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
4189        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4190        let err = super::tgm_decode(encoded.as_ptr(), encoded.len(), 0, 0, 0, &mut msg);
4191        assert!(matches!(err, super::TgmError::Ok));
4192
4193        // Index 1 does not exist (only index 0)
4194        assert_eq!(super::tgm_object_ndim(msg, 1), 0);
4195        assert!(super::tgm_object_shape(msg, 1).is_null());
4196        assert!(super::tgm_object_strides(msg, 1).is_null());
4197        assert!(super::tgm_object_dtype(msg, 1).is_null());
4198        assert!(super::tgm_object_type(msg, 1).is_null());
4199        assert!(super::tgm_object_byte_order(msg, 1).is_null());
4200        assert!(super::tgm_object_filter(msg, 1).is_null());
4201        assert!(super::tgm_object_compression(msg, 1).is_null());
4202        assert!(super::tgm_payload_encoding(msg, 1).is_null());
4203        assert_eq!(super::tgm_payload_has_hash(msg, 1), 0);
4204        assert!(super::tgm_object_hash_type(msg, 1).is_null());
4205        assert!(super::tgm_object_hash_value(msg, 1).is_null());
4206
4207        let mut data_len: usize = 99;
4208        let data_ptr = super::tgm_object_data(msg, 1, &mut data_len);
4209        assert!(data_ptr.is_null());
4210        assert_eq!(data_len, 0);
4211
4212        super::tgm_message_free(msg);
4213    }
4214
4215    // ── tgm_object_data with null out_len ──
4216
4217    #[test]
4218    fn ffi_object_data_null_out_len() {
4219        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
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        // null out_len should not crash
4225        let data_ptr = super::tgm_object_data(msg, 0, ptr::null_mut());
4226        assert!(!data_ptr.is_null());
4227
4228        super::tgm_message_free(msg);
4229    }
4230
4231    // ── tgm_decode_range ──
4232
4233    #[test]
4234    fn ffi_decode_range_round_trip() {
4235        let values = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
4236        let encoded = ffi_encode_single_f32_tensor(&values, "");
4237
4238        // Request elements [2..5) (3 elements)
4239        let range_offset: u64 = 2;
4240        let range_count: u64 = 3;
4241        let mut out_buf = super::TgmBytes {
4242            data: ptr::null_mut(),
4243            len: 0,
4244        };
4245        let mut out_count: usize = 0;
4246
4247        let err = super::tgm_decode_range(
4248            encoded.as_ptr(),
4249            encoded.len(),
4250            0,
4251            &range_offset as *const u64,
4252            &range_count as *const u64,
4253            1,
4254            0, // no native byte order
4255            0, // threads
4256            1, // join
4257            &mut out_buf,
4258            &mut out_count,
4259        );
4260        assert!(matches!(err, super::TgmError::Ok));
4261        assert_eq!(out_count, 1);
4262        assert!(!out_buf.data.is_null());
4263
4264        let decoded_bytes = unsafe { slice::from_raw_parts(out_buf.data, out_buf.len) };
4265        let decoded_values: Vec<f32> = decoded_bytes
4266            .chunks_exact(4)
4267            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
4268            .collect();
4269        assert_eq!(decoded_values, [3.0, 4.0, 5.0]);
4270
4271        super::tgm_bytes_free(out_buf);
4272    }
4273
4274    #[test]
4275    fn ffi_decode_range_split_mode() {
4276        let values = [10.0f32, 20.0, 30.0, 40.0];
4277        let encoded = ffi_encode_single_f32_tensor(&values, "");
4278
4279        // Two ranges: [0..2), [2..4)
4280        let range_offsets: [u64; 2] = [0, 2];
4281        let range_counts: [u64; 2] = [2, 2];
4282        let mut out_bufs = [
4283            super::TgmBytes {
4284                data: ptr::null_mut(),
4285                len: 0,
4286            },
4287            super::TgmBytes {
4288                data: ptr::null_mut(),
4289                len: 0,
4290            },
4291        ];
4292        let mut out_count: usize = 0;
4293
4294        let err = super::tgm_decode_range(
4295            encoded.as_ptr(),
4296            encoded.len(),
4297            0,
4298            range_offsets.as_ptr(),
4299            range_counts.as_ptr(),
4300            2,
4301            0,
4302            0, // threads
4303            0, // split mode (join=0)
4304            out_bufs.as_mut_ptr(),
4305            &mut out_count,
4306        );
4307        assert!(matches!(err, super::TgmError::Ok));
4308        assert_eq!(out_count, 2);
4309
4310        // First range: [10.0, 20.0]
4311        let bytes0 = unsafe { slice::from_raw_parts(out_bufs[0].data, out_bufs[0].len) };
4312        let vals0: Vec<f32> = bytes0
4313            .chunks_exact(4)
4314            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
4315            .collect();
4316        assert_eq!(vals0, [10.0, 20.0]);
4317
4318        // Second range: [30.0, 40.0]
4319        let bytes1 = unsafe { slice::from_raw_parts(out_bufs[1].data, out_bufs[1].len) };
4320        let vals1: Vec<f32> = bytes1
4321            .chunks_exact(4)
4322            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
4323            .collect();
4324        assert_eq!(vals1, [30.0, 40.0]);
4325
4326        // TgmBytes is not Copy, so manually construct values for free
4327        super::tgm_bytes_free(super::TgmBytes {
4328            data: out_bufs[0].data,
4329            len: out_bufs[0].len,
4330        });
4331        super::tgm_bytes_free(super::TgmBytes {
4332            data: out_bufs[1].data,
4333            len: out_bufs[1].len,
4334        });
4335    }
4336
4337    #[test]
4338    fn ffi_decode_range_null_args() {
4339        let mut out_buf = super::TgmBytes {
4340            data: ptr::null_mut(),
4341            len: 0,
4342        };
4343        let mut out_count: usize = 0;
4344
4345        // null buf
4346        let err = super::tgm_decode_range(
4347            ptr::null(),
4348            0,
4349            0,
4350            ptr::null(),
4351            ptr::null(),
4352            0,
4353            0,
4354            0,
4355            0,
4356            &mut out_buf,
4357            &mut out_count,
4358        );
4359        assert!(matches!(err, super::TgmError::InvalidArg));
4360
4361        // null out
4362        let data = [0u8; 10];
4363        let err = super::tgm_decode_range(
4364            data.as_ptr(),
4365            data.len(),
4366            0,
4367            ptr::null(),
4368            ptr::null(),
4369            0,
4370            0,
4371            0,
4372            0,
4373            ptr::null_mut(),
4374            &mut out_count,
4375        );
4376        assert!(matches!(err, super::TgmError::InvalidArg));
4377
4378        // null out_count
4379        let err = super::tgm_decode_range(
4380            data.as_ptr(),
4381            data.len(),
4382            0,
4383            ptr::null(),
4384            ptr::null(),
4385            0,
4386            0,
4387            0,
4388            0,
4389            &mut out_buf,
4390            ptr::null_mut(),
4391        );
4392        assert!(matches!(err, super::TgmError::InvalidArg));
4393    }
4394
4395    #[test]
4396    fn ffi_decode_range_null_ranges_with_nonzero_count() {
4397        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
4398        let mut out_buf = super::TgmBytes {
4399            data: ptr::null_mut(),
4400            len: 0,
4401        };
4402        let mut out_count: usize = 0;
4403
4404        let err = super::tgm_decode_range(
4405            encoded.as_ptr(),
4406            encoded.len(),
4407            0,
4408            ptr::null(), // null ranges_offsets
4409            ptr::null(), // null ranges_counts
4410            1,           // but num_ranges > 0
4411            0,
4412            0, // threads
4413            0,
4414            &mut out_buf,
4415            &mut out_count,
4416        );
4417        assert!(matches!(err, super::TgmError::InvalidArg));
4418    }
4419
4420    // ── tgm_scan ──
4421
4422    #[test]
4423    fn ffi_scan_single_message() {
4424        let encoded = ffi_encode_single_f32_tensor(&[1.0f32, 2.0], "");
4425
4426        let mut result: *mut super::TgmScanResult = ptr::null_mut();
4427        let err = super::tgm_scan(encoded.as_ptr(), encoded.len(), &mut result);
4428        assert!(matches!(err, super::TgmError::Ok));
4429        assert!(!result.is_null());
4430
4431        assert_eq!(super::tgm_scan_count(result), 1);
4432
4433        let entry = super::tgm_scan_entry(result, 0);
4434        assert_eq!(entry.offset, 0);
4435        assert_eq!(entry.length, encoded.len());
4436
4437        // Out of bounds entry returns sentinel (offset=usize::MAX, length=0)
4438        // and sets tgm_last_error
4439        let bad = super::tgm_scan_entry(result, 999);
4440        assert_eq!(bad.offset, usize::MAX);
4441        assert_eq!(bad.length, 0);
4442        let err_ptr = super::tgm_last_error();
4443        assert!(!err_ptr.is_null());
4444        let err_str = unsafe { CStr::from_ptr(err_ptr) }.to_str().unwrap();
4445        assert!(
4446            err_str.contains("out of range"),
4447            "expected OOB error, got: {err_str}"
4448        );
4449
4450        super::tgm_scan_free(result);
4451    }
4452
4453    #[test]
4454    fn ffi_scan_null_args() {
4455        let mut result: *mut super::TgmScanResult = ptr::null_mut();
4456        let err = super::tgm_scan(ptr::null(), 0, &mut result);
4457        assert!(matches!(err, super::TgmError::InvalidArg));
4458
4459        let data = [0u8; 10];
4460        let err = super::tgm_scan(data.as_ptr(), data.len(), ptr::null_mut());
4461        assert!(matches!(err, super::TgmError::InvalidArg));
4462    }
4463
4464    #[test]
4465    fn ffi_scan_null_handle_accessors() {
4466        assert_eq!(super::tgm_scan_count(ptr::null()), 0);
4467        let entry = super::tgm_scan_entry(ptr::null(), 0);
4468        assert_eq!(entry.offset, usize::MAX);
4469        assert_eq!(entry.length, 0);
4470    }
4471
4472    #[test]
4473    fn ffi_scan_concatenated_messages() {
4474        let msg1 = ffi_encode_single_f32_tensor(&[1.0f32], "");
4475        let msg2 = ffi_encode_single_f32_tensor(&[2.0f32], "");
4476        let mut concat = msg1.clone();
4477        concat.extend_from_slice(&msg2);
4478
4479        let mut result: *mut super::TgmScanResult = ptr::null_mut();
4480        let err = super::tgm_scan(concat.as_ptr(), concat.len(), &mut result);
4481        assert!(matches!(err, super::TgmError::Ok));
4482
4483        assert_eq!(super::tgm_scan_count(result), 2);
4484
4485        let e0 = super::tgm_scan_entry(result, 0);
4486        assert_eq!(e0.offset, 0);
4487        assert_eq!(e0.length, msg1.len());
4488
4489        let e1 = super::tgm_scan_entry(result, 1);
4490        assert_eq!(e1.offset, msg1.len());
4491        assert_eq!(e1.length, msg2.len());
4492
4493        super::tgm_scan_free(result);
4494    }
4495
4496    // ── tgm_file_* functions ──
4497
4498    #[test]
4499    fn ffi_file_create_append_count_decode_close() {
4500        let dir = std::env::temp_dir();
4501        let path = dir.join("ffi_test_file.tgm");
4502        let _ = std::fs::remove_file(&path);
4503
4504        let c_path = CString::new(path.to_str().unwrap()).unwrap();
4505
4506        // Create
4507        let mut file: *mut super::TgmFile = ptr::null_mut();
4508        let err = super::tgm_file_create(c_path.as_ptr(), &mut file);
4509        assert!(matches!(err, super::TgmError::Ok));
4510        assert!(!file.is_null());
4511
4512        // Append a message
4513        let values = [10.0f32, 20.0, 30.0];
4514        let shape_str = format!("[{}]", values.len());
4515        let json = format!(
4516            r#"{{"version":3,"descriptors":[{{"type":"ntensor","ndim":1,"shape":{shape},"strides":[1],"dtype":"float32","byte_order":"{bo}","encoding":"none","filter":"none","compression":"none"}}]}}"#,
4517            shape = shape_str,
4518            bo = if cfg!(target_endian = "little") {
4519                "little"
4520            } else {
4521                "big"
4522            },
4523        );
4524        let c_json = CString::new(json).unwrap();
4525        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
4526        let data_ptr: *const u8 = data.as_ptr();
4527        let data_len: usize = data.len();
4528
4529        let err = super::tgm_file_append(
4530            file,
4531            c_json.as_ptr(),
4532            &data_ptr as *const *const u8,
4533            &data_len as *const usize,
4534            1,
4535            ptr::null(),
4536            0,
4537        );
4538        assert!(matches!(err, super::TgmError::Ok));
4539
4540        // Check path accessor
4541        let path_ptr = super::tgm_file_path(file);
4542        assert!(!path_ptr.is_null());
4543        let path_str = unsafe { CStr::from_ptr(path_ptr) }.to_str().unwrap();
4544        assert!(path_str.contains("ffi_test_file.tgm"));
4545
4546        super::tgm_file_close(file);
4547
4548        // Re-open for reading
4549        let mut file2: *mut super::TgmFile = ptr::null_mut();
4550        let err = super::tgm_file_open(c_path.as_ptr(), &mut file2);
4551        assert!(matches!(err, super::TgmError::Ok));
4552
4553        // Message count
4554        let mut count: usize = 0;
4555        let err = super::tgm_file_message_count(file2, &mut count);
4556        assert!(matches!(err, super::TgmError::Ok));
4557        assert_eq!(count, 1);
4558
4559        // Decode message
4560        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4561        let err = super::tgm_file_decode_message(file2, 0, 0, 0, 0, &mut msg);
4562        assert!(matches!(err, super::TgmError::Ok));
4563
4564        assert_eq!(super::tgm_message_num_objects(msg), 1);
4565        let mut data_len2: usize = 0;
4566        let dp = super::tgm_object_data(msg, 0, &mut data_len2);
4567        assert!(!dp.is_null());
4568        let decoded_bytes = unsafe { slice::from_raw_parts(dp, data_len2) };
4569        let decoded_values: Vec<f32> = decoded_bytes
4570            .chunks_exact(4)
4571            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
4572            .collect();
4573        assert_eq!(decoded_values, values);
4574
4575        super::tgm_message_free(msg);
4576
4577        // Read raw message
4578        let mut raw = super::TgmBytes {
4579            data: ptr::null_mut(),
4580            len: 0,
4581        };
4582        let err = super::tgm_file_read_message(file2, 0, &mut raw);
4583        assert!(matches!(err, super::TgmError::Ok));
4584        assert!(!raw.data.is_null());
4585        assert!(raw.len > 0);
4586        super::tgm_bytes_free(raw);
4587
4588        super::tgm_file_close(file2);
4589        let _ = std::fs::remove_file(&path);
4590    }
4591
4592    #[test]
4593    fn ffi_file_open_nonexistent() {
4594        let c_path = CString::new("/nonexistent/file.tgm").unwrap();
4595        let mut file: *mut super::TgmFile = ptr::null_mut();
4596        let err = super::tgm_file_open(c_path.as_ptr(), &mut file);
4597        assert!(!matches!(err, super::TgmError::Ok));
4598    }
4599
4600    #[test]
4601    fn ffi_file_null_args() {
4602        let mut file: *mut super::TgmFile = ptr::null_mut();
4603
4604        // open null path
4605        let err = super::tgm_file_open(ptr::null(), &mut file);
4606        assert!(matches!(err, super::TgmError::InvalidArg));
4607
4608        // open null out
4609        let c_path = CString::new("/tmp/test.tgm").unwrap();
4610        let err = super::tgm_file_open(c_path.as_ptr(), ptr::null_mut());
4611        assert!(matches!(err, super::TgmError::InvalidArg));
4612
4613        // create null path
4614        let err = super::tgm_file_create(ptr::null(), &mut file);
4615        assert!(matches!(err, super::TgmError::InvalidArg));
4616
4617        // create null out
4618        let err = super::tgm_file_create(c_path.as_ptr(), ptr::null_mut());
4619        assert!(matches!(err, super::TgmError::InvalidArg));
4620
4621        // message_count null args
4622        let err = super::tgm_file_message_count(ptr::null_mut(), ptr::null_mut());
4623        assert!(matches!(err, super::TgmError::InvalidArg));
4624
4625        // decode_message null args
4626        let err = super::tgm_file_decode_message(ptr::null_mut(), 0, 0, 0, 0, ptr::null_mut());
4627        assert!(matches!(err, super::TgmError::InvalidArg));
4628
4629        // read_message null args
4630        let err = super::tgm_file_read_message(ptr::null_mut(), 0, ptr::null_mut());
4631        assert!(matches!(err, super::TgmError::InvalidArg));
4632
4633        // append null args
4634        let err = super::tgm_file_append(
4635            ptr::null_mut(),
4636            ptr::null(),
4637            ptr::null(),
4638            ptr::null(),
4639            0,
4640            ptr::null(),
4641            0,
4642        );
4643        assert!(matches!(err, super::TgmError::InvalidArg));
4644
4645        // append_raw null args
4646        let err = super::tgm_file_append_raw(ptr::null_mut(), ptr::null(), 0);
4647        assert!(matches!(err, super::TgmError::InvalidArg));
4648
4649        // path null
4650        assert!(super::tgm_file_path(ptr::null()).is_null());
4651    }
4652
4653    #[test]
4654    fn ffi_file_append_raw_round_trip() {
4655        let dir = std::env::temp_dir();
4656        let path = dir.join("ffi_test_append_raw.tgm");
4657        let _ = std::fs::remove_file(&path);
4658
4659        let c_path = CString::new(path.to_str().unwrap()).unwrap();
4660
4661        // Create
4662        let mut file: *mut super::TgmFile = ptr::null_mut();
4663        let err = super::tgm_file_create(c_path.as_ptr(), &mut file);
4664        assert!(matches!(err, super::TgmError::Ok));
4665
4666        // Encode a message in memory, then append raw bytes
4667        let encoded = ffi_encode_single_f32_tensor(&[1.0f32, 2.0], "");
4668        let err = super::tgm_file_append_raw(file, encoded.as_ptr(), encoded.len());
4669        assert!(matches!(err, super::TgmError::Ok));
4670
4671        // Count
4672        let mut count: usize = 0;
4673        let err = super::tgm_file_message_count(file, &mut count);
4674        assert!(matches!(err, super::TgmError::Ok));
4675        assert_eq!(count, 1);
4676
4677        super::tgm_file_close(file);
4678        let _ = std::fs::remove_file(&path);
4679    }
4680
4681    // ── tgm_streaming_encoder_* ──
4682
4683    #[test]
4684    fn ffi_streaming_encoder_round_trip() {
4685        let dir = std::env::temp_dir();
4686        let path = dir.join("ffi_streaming_test.tgm");
4687        let _ = std::fs::remove_file(&path);
4688
4689        let c_path = CString::new(path.to_str().unwrap()).unwrap();
4690        let meta_json = CString::new(r#"{"version":3}"#).unwrap();
4691
4692        // Create
4693        let mut enc: *mut super::TgmStreamingEncoder = ptr::null_mut();
4694        let err = super::tgm_streaming_encoder_create(
4695            c_path.as_ptr(),
4696            meta_json.as_ptr(),
4697            ptr::null(), // no hash
4698            0,           // threads
4699            &mut enc,
4700        );
4701        assert!(matches!(err, super::TgmError::Ok));
4702        assert!(!enc.is_null());
4703
4704        // Write an object
4705        let values = [100.0f32, 200.0, 300.0];
4706        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
4707        let desc_json = CString::new(format!(
4708            r#"{{"type":"ntensor","ndim":1,"shape":[{len}],"strides":[1],"dtype":"float32","byte_order":"{bo}","encoding":"none","filter":"none","compression":"none"}}"#,
4709            len = values.len(),
4710            bo = if cfg!(target_endian = "little") { "little" } else { "big" },
4711        )).unwrap();
4712
4713        let err =
4714            super::tgm_streaming_encoder_write(enc, desc_json.as_ptr(), data.as_ptr(), data.len());
4715        assert!(matches!(err, super::TgmError::Ok));
4716
4717        // Count
4718        assert_eq!(super::tgm_streaming_encoder_count(enc), 1);
4719
4720        // Finish
4721        let err = super::tgm_streaming_encoder_finish(enc);
4722        assert!(matches!(err, super::TgmError::Ok));
4723
4724        // Double finish should fail
4725        let err = super::tgm_streaming_encoder_finish(enc);
4726        assert!(matches!(err, super::TgmError::InvalidArg));
4727
4728        // Count after finish
4729        assert_eq!(super::tgm_streaming_encoder_count(enc), 0);
4730
4731        // Free
4732        super::tgm_streaming_encoder_free(enc);
4733
4734        // Read back and verify
4735        let mut file: *mut super::TgmFile = ptr::null_mut();
4736        let err = super::tgm_file_open(c_path.as_ptr(), &mut file);
4737        assert!(matches!(err, super::TgmError::Ok));
4738
4739        let mut count: usize = 0;
4740        let err = super::tgm_file_message_count(file, &mut count);
4741        assert!(matches!(err, super::TgmError::Ok));
4742        assert_eq!(count, 1);
4743
4744        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4745        let err = super::tgm_file_decode_message(file, 0, 0, 0, 0, &mut msg);
4746        assert!(matches!(err, super::TgmError::Ok));
4747
4748        let mut data_len: usize = 0;
4749        let dp = super::tgm_object_data(msg, 0, &mut data_len);
4750        let decoded_bytes = unsafe { slice::from_raw_parts(dp, data_len) };
4751        let decoded_values: Vec<f32> = decoded_bytes
4752            .chunks_exact(4)
4753            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
4754            .collect();
4755        assert_eq!(decoded_values, values);
4756
4757        super::tgm_message_free(msg);
4758        super::tgm_file_close(file);
4759        let _ = std::fs::remove_file(&path);
4760    }
4761
4762    #[test]
4763    fn ffi_streaming_encoder_null_args() {
4764        let mut enc: *mut super::TgmStreamingEncoder = ptr::null_mut();
4765
4766        // create with null path
4767        let meta = CString::new(r#"{"version":3}"#).unwrap();
4768        let err = super::tgm_streaming_encoder_create(
4769            ptr::null(),
4770            meta.as_ptr(),
4771            ptr::null(),
4772            0,
4773            &mut enc,
4774        );
4775        assert!(matches!(err, super::TgmError::InvalidArg));
4776
4777        // create with null metadata
4778        let p = CString::new("/tmp/dummy.tgm").unwrap();
4779        let err =
4780            super::tgm_streaming_encoder_create(p.as_ptr(), ptr::null(), ptr::null(), 0, &mut enc);
4781        assert!(matches!(err, super::TgmError::InvalidArg));
4782
4783        // create with null out
4784        let err = super::tgm_streaming_encoder_create(
4785            p.as_ptr(),
4786            meta.as_ptr(),
4787            ptr::null(),
4788            0,
4789            ptr::null_mut(),
4790        );
4791        assert!(matches!(err, super::TgmError::InvalidArg));
4792
4793        // write null enc
4794        let desc = CString::new(r#"{}"#).unwrap();
4795        let data = [0u8; 4];
4796        let err = super::tgm_streaming_encoder_write(
4797            ptr::null_mut(),
4798            desc.as_ptr(),
4799            data.as_ptr(),
4800            data.len(),
4801        );
4802        assert!(matches!(err, super::TgmError::InvalidArg));
4803
4804        // write null descriptor
4805        // Need a valid encoder for this — skip as it requires file creation
4806
4807        // finish null
4808        let err = super::tgm_streaming_encoder_finish(ptr::null_mut());
4809        assert!(matches!(err, super::TgmError::InvalidArg));
4810
4811        // count null
4812        assert_eq!(super::tgm_streaming_encoder_count(ptr::null()), 0);
4813
4814        // free null — should not crash
4815        super::tgm_streaming_encoder_free(ptr::null_mut());
4816    }
4817
4818    #[test]
4819    fn ffi_streaming_encoder_write_null_data() {
4820        let dir = std::env::temp_dir();
4821        let path = dir.join("ffi_streaming_null_data.tgm");
4822        let _ = std::fs::remove_file(&path);
4823
4824        let c_path = CString::new(path.to_str().unwrap()).unwrap();
4825        let meta_json = CString::new(r#"{"version":3}"#).unwrap();
4826
4827        let mut enc: *mut super::TgmStreamingEncoder = ptr::null_mut();
4828        let err = super::tgm_streaming_encoder_create(
4829            c_path.as_ptr(),
4830            meta_json.as_ptr(),
4831            ptr::null(),
4832            0,
4833            &mut enc,
4834        );
4835        assert!(matches!(err, super::TgmError::Ok));
4836
4837        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();
4838        let err = super::tgm_streaming_encoder_write(enc, desc.as_ptr(), ptr::null(), 4);
4839        assert!(matches!(err, super::TgmError::InvalidArg));
4840
4841        // Write with null descriptor json
4842        let data = [0u8; 4];
4843        let err = super::tgm_streaming_encoder_write(enc, ptr::null(), data.as_ptr(), data.len());
4844        assert!(matches!(err, super::TgmError::InvalidArg));
4845
4846        super::tgm_streaming_encoder_free(enc);
4847        let _ = std::fs::remove_file(&path);
4848    }
4849
4850    #[test]
4851    fn ffi_streaming_encoder_with_preceder() {
4852        let dir = std::env::temp_dir();
4853        let path = dir.join("ffi_streaming_preceder.tgm");
4854        let _ = std::fs::remove_file(&path);
4855
4856        let c_path = CString::new(path.to_str().unwrap()).unwrap();
4857        let meta_json = CString::new(r#"{"version":3}"#).unwrap();
4858
4859        let mut enc: *mut super::TgmStreamingEncoder = ptr::null_mut();
4860        let err = super::tgm_streaming_encoder_create(
4861            c_path.as_ptr(),
4862            meta_json.as_ptr(),
4863            ptr::null(),
4864            0,
4865            &mut enc,
4866        );
4867        assert!(matches!(err, super::TgmError::Ok));
4868
4869        // Write preceder
4870        let preceder_json = CString::new(r#"{"param":"2t","source":"test"}"#).unwrap();
4871        let err = super::tgm_streaming_encoder_write_preceder(enc, preceder_json.as_ptr());
4872        assert!(matches!(err, super::TgmError::Ok));
4873
4874        // Write object after preceder
4875        let values = [42.0f32];
4876        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
4877        let desc_json = CString::new(format!(
4878            r#"{{"type":"ntensor","ndim":1,"shape":[1],"strides":[1],"dtype":"float32","byte_order":"{bo}","encoding":"none","filter":"none","compression":"none"}}"#,
4879            bo = if cfg!(target_endian = "little") { "little" } else { "big" },
4880        )).unwrap();
4881
4882        let err =
4883            super::tgm_streaming_encoder_write(enc, desc_json.as_ptr(), data.as_ptr(), data.len());
4884        assert!(matches!(err, super::TgmError::Ok));
4885
4886        let err = super::tgm_streaming_encoder_finish(enc);
4887        assert!(matches!(err, super::TgmError::Ok));
4888        super::tgm_streaming_encoder_free(enc);
4889
4890        // Re-open and verify the metadata contains the preceder keys
4891        let mut file: *mut super::TgmFile = ptr::null_mut();
4892        let err = super::tgm_file_open(c_path.as_ptr(), &mut file);
4893        assert!(matches!(err, super::TgmError::Ok));
4894
4895        let mut msg: *mut super::TgmMessage = ptr::null_mut();
4896        let err = super::tgm_file_decode_message(file, 0, 0, 0, 0, &mut msg);
4897        assert!(matches!(err, super::TgmError::Ok));
4898
4899        let mut meta: *mut super::TgmMetadata = ptr::null_mut();
4900        let err = super::tgm_message_metadata(msg, &mut meta);
4901        assert!(matches!(err, super::TgmError::Ok));
4902
4903        let key = CString::new("param").unwrap();
4904        let val_ptr = super::tgm_metadata_get_string(meta, key.as_ptr());
4905        assert!(!val_ptr.is_null());
4906        let val_str = unsafe { CStr::from_ptr(val_ptr) }.to_str().unwrap();
4907        assert_eq!(val_str, "2t");
4908
4909        super::tgm_metadata_free(meta);
4910        super::tgm_message_free(msg);
4911        super::tgm_file_close(file);
4912        let _ = std::fs::remove_file(&path);
4913    }
4914
4915    #[test]
4916    fn ffi_streaming_encoder_write_preceder_null_args() {
4917        let err = super::tgm_streaming_encoder_write_preceder(ptr::null_mut(), ptr::null());
4918        assert!(matches!(err, super::TgmError::InvalidArg));
4919    }
4920
4921    #[test]
4922    fn ffi_streaming_encoder_write_pre_encoded_null_args() {
4923        let err = super::tgm_streaming_encoder_write_pre_encoded(
4924            ptr::null_mut(),
4925            ptr::null(),
4926            ptr::null(),
4927            0,
4928        );
4929        assert!(matches!(err, super::TgmError::InvalidArg));
4930    }
4931
4932    // ── tgm_compute_hash ──
4933
4934    #[test]
4935    fn ffi_compute_hash_xxh3() {
4936        let data = b"hello world";
4937        let mut out = super::TgmBytes {
4938            data: ptr::null_mut(),
4939            len: 0,
4940        };
4941        let err = super::tgm_compute_hash(
4942            data.as_ptr(),
4943            data.len(),
4944            ptr::null(), // default = xxh3
4945            &mut out,
4946        );
4947        assert!(matches!(err, super::TgmError::Ok));
4948        assert!(!out.data.is_null());
4949        assert!(out.len > 0);
4950
4951        let hex = unsafe { std::str::from_utf8(slice::from_raw_parts(out.data, out.len)).unwrap() };
4952        // xxh3 produces a hex string
4953        assert!(!hex.is_empty());
4954        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
4955
4956        super::tgm_bytes_free(out);
4957    }
4958
4959    #[test]
4960    fn ffi_compute_hash_explicit_xxh3() {
4961        let data = b"test data";
4962        let algo = CString::new("xxh3").unwrap();
4963        let mut out = super::TgmBytes {
4964            data: ptr::null_mut(),
4965            len: 0,
4966        };
4967        let err = super::tgm_compute_hash(data.as_ptr(), data.len(), algo.as_ptr(), &mut out);
4968        assert!(matches!(err, super::TgmError::Ok));
4969        assert!(out.len > 0);
4970        super::tgm_bytes_free(out);
4971    }
4972
4973    #[test]
4974    fn ffi_compute_hash_null_data() {
4975        let mut out = super::TgmBytes {
4976            data: ptr::null_mut(),
4977            len: 0,
4978        };
4979        let err = super::tgm_compute_hash(ptr::null(), 0, ptr::null(), &mut out);
4980        assert!(matches!(err, super::TgmError::InvalidArg));
4981    }
4982
4983    #[test]
4984    fn ffi_compute_hash_null_out() {
4985        let data = b"hello";
4986        let err = super::tgm_compute_hash(data.as_ptr(), data.len(), ptr::null(), ptr::null_mut());
4987        assert!(matches!(err, super::TgmError::InvalidArg));
4988    }
4989
4990    #[test]
4991    fn ffi_compute_hash_invalid_algo() {
4992        let data = b"hello";
4993        let algo = CString::new("bogus_algo").unwrap();
4994        let mut out = super::TgmBytes {
4995            data: ptr::null_mut(),
4996            len: 0,
4997        };
4998        let err = super::tgm_compute_hash(data.as_ptr(), data.len(), algo.as_ptr(), &mut out);
4999        assert!(matches!(err, super::TgmError::InvalidArg));
5000    }
5001
5002    // ── tgm_simple_packing_compute_params ──
5003
5004    #[test]
5005    fn ffi_simple_packing_compute_params() {
5006        let values = [100.0f64, 200.0, 300.0, 400.0];
5007        let mut ref_val: f64 = 0.0;
5008        let mut bin_scale: i32 = 0;
5009        let err = super::tgm_simple_packing_compute_params(
5010            values.as_ptr(),
5011            values.len(),
5012            16,
5013            0,
5014            &mut ref_val,
5015            &mut bin_scale,
5016        );
5017        assert!(matches!(err, super::TgmError::Ok));
5018        // Reference value should be <= min(values)
5019        assert!(ref_val <= 100.0);
5020    }
5021
5022    #[test]
5023    fn ffi_simple_packing_compute_params_null_args() {
5024        let mut ref_val: f64 = 0.0;
5025        let mut bin_scale: i32 = 0;
5026
5027        // null values
5028        let err = super::tgm_simple_packing_compute_params(
5029            ptr::null(),
5030            0,
5031            16,
5032            0,
5033            &mut ref_val,
5034            &mut bin_scale,
5035        );
5036        assert!(matches!(err, super::TgmError::InvalidArg));
5037
5038        // null out_reference_value
5039        let values = [1.0f64];
5040        let err = super::tgm_simple_packing_compute_params(
5041            values.as_ptr(),
5042            values.len(),
5043            16,
5044            0,
5045            ptr::null_mut(),
5046            &mut bin_scale,
5047        );
5048        assert!(matches!(err, super::TgmError::InvalidArg));
5049
5050        // null out_binary_scale_factor
5051        let err = super::tgm_simple_packing_compute_params(
5052            values.as_ptr(),
5053            values.len(),
5054            16,
5055            0,
5056            &mut ref_val,
5057            ptr::null_mut(),
5058        );
5059        assert!(matches!(err, super::TgmError::InvalidArg));
5060    }
5061
5062    // ── tgm_last_error ──
5063
5064    #[test]
5065    fn ffi_last_error_after_success() {
5066        // After a successful encode, last_error should remain from whatever was
5067        // set before (or NULL). We don't clear on success.
5068        let values = [1.0f32];
5069        let _ = ffi_encode_single_f32_tensor(&values, "");
5070        // No crash, test passes
5071    }
5072
5073    #[test]
5074    fn ffi_last_error_after_failure() {
5075        // Trigger an error
5076        let mut out = super::TgmBytes {
5077            data: ptr::null_mut(),
5078            len: 0,
5079        };
5080        let _ = super::tgm_encode(
5081            ptr::null(),
5082            ptr::null(),
5083            ptr::null(),
5084            0,
5085            ptr::null(),
5086            0,
5087            &mut out,
5088        );
5089
5090        let err_ptr = super::tgm_last_error();
5091        assert!(!err_ptr.is_null());
5092        let err_str = unsafe { CStr::from_ptr(err_ptr) }.to_str().unwrap();
5093        assert!(err_str.contains("null"));
5094    }
5095
5096    // ── tgm_error_string ──
5097
5098    #[test]
5099    fn ffi_error_string_all_variants() {
5100        let check = |err: super::TgmError, expected: &str| {
5101            let ptr = super::tgm_error_string(err);
5102            assert!(!ptr.is_null());
5103            let s = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap();
5104            assert_eq!(s, expected);
5105        };
5106
5107        check(super::TgmError::Ok, "ok");
5108        check(super::TgmError::Framing, "framing error");
5109        check(super::TgmError::Metadata, "metadata error");
5110        check(super::TgmError::Encoding, "encoding error");
5111        check(super::TgmError::Compression, "compression error");
5112        check(super::TgmError::Object, "object error");
5113        check(super::TgmError::Io, "I/O error");
5114        check(super::TgmError::HashMismatch, "hash mismatch");
5115        check(super::TgmError::InvalidArg, "invalid argument");
5116        check(super::TgmError::EndOfIter, "end of iteration");
5117        check(super::TgmError::Remote, "remote error");
5118    }
5119
5120    // ── tgm_bytes_free safety ──
5121
5122    #[test]
5123    fn ffi_bytes_free_null_data() {
5124        let buf = super::TgmBytes {
5125            data: ptr::null_mut(),
5126            len: 0,
5127        };
5128        super::tgm_bytes_free(buf); // should not crash
5129    }
5130
5131    // ── tgm_message_free / tgm_metadata_free / tgm_scan_free null safety ──
5132
5133    #[test]
5134    fn ffi_free_null_handles() {
5135        super::tgm_message_free(ptr::null_mut());
5136        super::tgm_metadata_free(ptr::null_mut());
5137        super::tgm_scan_free(ptr::null_mut());
5138        super::tgm_file_close(ptr::null_mut());
5139        super::tgm_streaming_encoder_free(ptr::null_mut());
5140        // Should all be no-ops, no crash
5141    }
5142
5143    // ── tgm_encode_pre_encoded ──
5144
5145    #[test]
5146    fn ffi_encode_pre_encoded_null_args() {
5147        let mut out = super::TgmBytes {
5148            data: ptr::null_mut(),
5149            len: 0,
5150        };
5151        let err = super::tgm_encode_pre_encoded(
5152            ptr::null(),
5153            ptr::null(),
5154            ptr::null(),
5155            0,
5156            ptr::null(),
5157            0,
5158            &mut out,
5159        );
5160        assert!(matches!(err, super::TgmError::InvalidArg));
5161
5162        let json = CString::new(r#"{"version":3,"descriptors":[]}"#).unwrap();
5163        let err = super::tgm_encode_pre_encoded(
5164            json.as_ptr(),
5165            ptr::null(),
5166            ptr::null(),
5167            0,
5168            ptr::null(),
5169            0,
5170            ptr::null_mut(),
5171        );
5172        assert!(matches!(err, super::TgmError::InvalidArg));
5173    }
5174
5175    #[test]
5176    fn ffi_encode_pre_encoded_round_trip() {
5177        // Encode with pre_encoded: for "none" encoding, the raw bytes are the payload
5178        let values = [5.0f32, 6.0, 7.0];
5179        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
5180
5181        let json = format!(
5182            r#"{{"version":3,"descriptors":[{{"type":"ntensor","ndim":1,"shape":[{len}],"strides":[1],"dtype":"float32","byte_order":"{bo}","encoding":"none","filter":"none","compression":"none"}}]}}"#,
5183            len = values.len(),
5184            bo = if cfg!(target_endian = "little") {
5185                "little"
5186            } else {
5187                "big"
5188            },
5189        );
5190        let c_json = CString::new(json).unwrap();
5191        let data_ptr: *const u8 = data.as_ptr();
5192        let data_len: usize = data.len();
5193
5194        let mut out = super::TgmBytes {
5195            data: ptr::null_mut(),
5196            len: 0,
5197        };
5198        let err = super::tgm_encode_pre_encoded(
5199            c_json.as_ptr(),
5200            &data_ptr as *const *const u8,
5201            &data_len as *const usize,
5202            1,
5203            ptr::null(),
5204            0,
5205            &mut out,
5206        );
5207        assert!(matches!(err, super::TgmError::Ok));
5208
5209        let encoded = unsafe { slice::from_raw_parts(out.data, out.len) }.to_vec();
5210        super::tgm_bytes_free(out);
5211
5212        // Decode
5213        let mut msg: *mut super::TgmMessage = ptr::null_mut();
5214        let err = super::tgm_decode(encoded.as_ptr(), encoded.len(), 0, 0, 0, &mut msg);
5215        assert!(matches!(err, super::TgmError::Ok));
5216
5217        let mut dl: usize = 0;
5218        let dp = super::tgm_object_data(msg, 0, &mut dl);
5219        let decoded_bytes = unsafe { slice::from_raw_parts(dp, dl) };
5220        let decoded_values: Vec<f32> = decoded_bytes
5221            .chunks_exact(4)
5222            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
5223            .collect();
5224        assert_eq!(decoded_values, values);
5225
5226        super::tgm_message_free(msg);
5227    }
5228
5229    // ── tgm_buffer_iter_* ──
5230
5231    #[test]
5232    fn ffi_buffer_iter_round_trip() {
5233        let msg1 = ffi_encode_single_f32_tensor(&[1.0f32], "");
5234        let msg2 = ffi_encode_single_f32_tensor(&[2.0f32], "");
5235        let mut concat = msg1.clone();
5236        concat.extend_from_slice(&msg2);
5237
5238        let mut iter: *mut super::TgmBufferIter = ptr::null_mut();
5239        let err = super::tgm_buffer_iter_create(concat.as_ptr(), concat.len(), &mut iter);
5240        assert!(matches!(err, super::TgmError::Ok));
5241        assert!(!iter.is_null());
5242
5243        assert_eq!(super::tgm_buffer_iter_count(iter), 2);
5244
5245        // First message
5246        let mut out_buf: *const u8 = ptr::null();
5247        let mut out_len: usize = 0;
5248        let err = super::tgm_buffer_iter_next(iter, &mut out_buf, &mut out_len);
5249        assert!(matches!(err, super::TgmError::Ok));
5250        assert!(!out_buf.is_null());
5251        assert_eq!(out_len, msg1.len());
5252
5253        // Second message
5254        let err = super::tgm_buffer_iter_next(iter, &mut out_buf, &mut out_len);
5255        assert!(matches!(err, super::TgmError::Ok));
5256        assert_eq!(out_len, msg2.len());
5257
5258        // End of iteration
5259        let err = super::tgm_buffer_iter_next(iter, &mut out_buf, &mut out_len);
5260        assert!(matches!(err, super::TgmError::EndOfIter));
5261
5262        super::tgm_buffer_iter_free(iter);
5263    }
5264
5265    #[test]
5266    fn ffi_buffer_iter_null_args() {
5267        let mut iter: *mut super::TgmBufferIter = ptr::null_mut();
5268        let err = super::tgm_buffer_iter_create(ptr::null(), 0, &mut iter);
5269        assert!(matches!(err, super::TgmError::InvalidArg));
5270
5271        let data = [0u8; 10];
5272        let err = super::tgm_buffer_iter_create(data.as_ptr(), data.len(), ptr::null_mut());
5273        assert!(matches!(err, super::TgmError::InvalidArg));
5274
5275        // next with null iter
5276        let mut out_buf: *const u8 = ptr::null();
5277        let mut out_len: usize = 0;
5278        let err = super::tgm_buffer_iter_next(ptr::null_mut(), &mut out_buf, &mut out_len);
5279        assert!(matches!(err, super::TgmError::InvalidArg));
5280
5281        // next with null out_buf
5282        // We need a valid iter for this, but let's test the easy null cases
5283        let err = super::tgm_buffer_iter_next(ptr::null_mut(), ptr::null_mut(), &mut out_len);
5284        assert!(matches!(err, super::TgmError::InvalidArg));
5285
5286        // count null
5287        assert_eq!(super::tgm_buffer_iter_count(ptr::null()), 0);
5288
5289        // free null — no crash
5290        super::tgm_buffer_iter_free(ptr::null_mut());
5291    }
5292
5293    // ── tgm_object_iter_* ──
5294
5295    #[test]
5296    fn ffi_object_iter_round_trip() {
5297        let encoded = ffi_encode_single_f32_tensor(&[10.0f32, 20.0], "");
5298
5299        let mut iter: *mut super::TgmObjectIter = ptr::null_mut();
5300        let err = super::tgm_object_iter_create(
5301            encoded.as_ptr(),
5302            encoded.len(),
5303            0, // no native byte order
5304            0, // verify_hash
5305            &mut iter,
5306        );
5307        assert!(matches!(err, super::TgmError::Ok));
5308        assert!(!iter.is_null());
5309
5310        // Get the single object
5311        let mut msg: *mut super::TgmMessage = ptr::null_mut();
5312        let err = super::tgm_object_iter_next(iter, &mut msg);
5313        assert!(matches!(err, super::TgmError::Ok));
5314        assert!(!msg.is_null());
5315
5316        assert_eq!(super::tgm_message_num_objects(msg), 1);
5317        assert_eq!(super::tgm_message_version(msg), 3);
5318
5319        let mut data_len: usize = 0;
5320        let dp = super::tgm_object_data(msg, 0, &mut data_len);
5321        assert!(!dp.is_null());
5322        assert_eq!(data_len, 8); // 2 × f32
5323
5324        super::tgm_message_free(msg);
5325
5326        // Iteration should be exhausted
5327        let mut msg2: *mut super::TgmMessage = ptr::null_mut();
5328        let err = super::tgm_object_iter_next(iter, &mut msg2);
5329        assert!(matches!(err, super::TgmError::EndOfIter));
5330
5331        super::tgm_object_iter_free(iter);
5332    }
5333
5334    #[test]
5335    fn ffi_object_iter_null_args() {
5336        let mut iter: *mut super::TgmObjectIter = ptr::null_mut();
5337        let err = super::tgm_object_iter_create(ptr::null(), 0, 0, 0, &mut iter);
5338        assert!(matches!(err, super::TgmError::InvalidArg));
5339
5340        let data = [0u8; 10];
5341        let err = super::tgm_object_iter_create(data.as_ptr(), data.len(), 0, 0, ptr::null_mut());
5342        assert!(matches!(err, super::TgmError::InvalidArg));
5343
5344        // next null iter
5345        let mut msg: *mut super::TgmMessage = ptr::null_mut();
5346        let err = super::tgm_object_iter_next(ptr::null_mut(), &mut msg);
5347        assert!(matches!(err, super::TgmError::InvalidArg));
5348
5349        // next null out
5350        let err = super::tgm_object_iter_next(ptr::null_mut(), ptr::null_mut());
5351        assert!(matches!(err, super::TgmError::InvalidArg));
5352
5353        // free null — no crash
5354        super::tgm_object_iter_free(ptr::null_mut());
5355    }
5356
5357    // ── tgm_file_iter_* ──
5358
5359    #[test]
5360    fn ffi_file_iter_round_trip() {
5361        let dir = std::env::temp_dir();
5362        let path = dir.join("ffi_file_iter_test.tgm");
5363        let _ = std::fs::remove_file(&path);
5364
5365        let c_path = CString::new(path.to_str().unwrap()).unwrap();
5366
5367        // Create file with 2 messages
5368        let mut file: *mut super::TgmFile = ptr::null_mut();
5369        let err = super::tgm_file_create(c_path.as_ptr(), &mut file);
5370        assert!(matches!(err, super::TgmError::Ok));
5371
5372        let msg1 = ffi_encode_single_f32_tensor(&[1.0f32], "");
5373        let msg2 = ffi_encode_single_f32_tensor(&[2.0f32], "");
5374        let err = super::tgm_file_append_raw(file, msg1.as_ptr(), msg1.len());
5375        assert!(matches!(err, super::TgmError::Ok));
5376        let err = super::tgm_file_append_raw(file, msg2.as_ptr(), msg2.len());
5377        assert!(matches!(err, super::TgmError::Ok));
5378
5379        // Create iterator
5380        let mut iter: *mut super::TgmFileIter = ptr::null_mut();
5381        let err = super::tgm_file_iter_create(file, &mut iter);
5382        assert!(matches!(err, super::TgmError::Ok));
5383        assert!(!iter.is_null());
5384
5385        // First message
5386        let mut out = super::TgmBytes {
5387            data: ptr::null_mut(),
5388            len: 0,
5389        };
5390        let err = super::tgm_file_iter_next(iter, &mut out);
5391        assert!(matches!(err, super::TgmError::Ok));
5392        assert!(!out.data.is_null());
5393        assert_eq!(out.len, msg1.len());
5394        super::tgm_bytes_free(out);
5395
5396        // Second message
5397        let mut out2 = super::TgmBytes {
5398            data: ptr::null_mut(),
5399            len: 0,
5400        };
5401        let err = super::tgm_file_iter_next(iter, &mut out2);
5402        assert!(matches!(err, super::TgmError::Ok));
5403        super::tgm_bytes_free(out2);
5404
5405        // End
5406        let mut out3 = super::TgmBytes {
5407            data: ptr::null_mut(),
5408            len: 0,
5409        };
5410        let err = super::tgm_file_iter_next(iter, &mut out3);
5411        assert!(matches!(err, super::TgmError::EndOfIter));
5412
5413        super::tgm_file_iter_free(iter);
5414        super::tgm_file_close(file);
5415        let _ = std::fs::remove_file(&path);
5416    }
5417
5418    #[test]
5419    fn ffi_file_iter_null_args() {
5420        let mut iter: *mut super::TgmFileIter = ptr::null_mut();
5421        let err = super::tgm_file_iter_create(ptr::null_mut(), &mut iter);
5422        assert!(matches!(err, super::TgmError::InvalidArg));
5423
5424        let err = super::tgm_file_iter_create(ptr::null_mut(), ptr::null_mut());
5425        assert!(matches!(err, super::TgmError::InvalidArg));
5426
5427        // next null
5428        let mut out = super::TgmBytes {
5429            data: ptr::null_mut(),
5430            len: 0,
5431        };
5432        let err = super::tgm_file_iter_next(ptr::null_mut(), &mut out);
5433        assert!(matches!(err, super::TgmError::InvalidArg));
5434
5435        let err = super::tgm_file_iter_next(ptr::null_mut(), ptr::null_mut());
5436        assert!(matches!(err, super::TgmError::InvalidArg));
5437
5438        // free null — no crash
5439        super::tgm_file_iter_free(ptr::null_mut());
5440    }
5441
5442    // ── tgm_encode zero objects (metadata-only message) ──
5443
5444    #[test]
5445    fn ffi_encode_decode_zero_objects() {
5446        let json = CString::new(r#"{"version":3,"descriptors":[],"source":"empty"}"#).unwrap();
5447        let mut out = super::TgmBytes {
5448            data: ptr::null_mut(),
5449            len: 0,
5450        };
5451        let err = super::tgm_encode(
5452            json.as_ptr(),
5453            ptr::null(),
5454            ptr::null(),
5455            0,
5456            ptr::null(),
5457            0,
5458            &mut out,
5459        );
5460        assert!(matches!(err, super::TgmError::Ok));
5461
5462        let encoded = unsafe { slice::from_raw_parts(out.data, out.len) }.to_vec();
5463        super::tgm_bytes_free(out);
5464
5465        let mut msg: *mut super::TgmMessage = ptr::null_mut();
5466        let err = super::tgm_decode(encoded.as_ptr(), encoded.len(), 0, 0, 0, &mut msg);
5467        assert!(matches!(err, super::TgmError::Ok));
5468
5469        assert_eq!(super::tgm_message_version(msg), 3);
5470        assert_eq!(super::tgm_message_num_objects(msg), 0);
5471
5472        super::tgm_message_free(msg);
5473    }
5474
5475    // ── tgm_streaming_encoder with hash ──
5476
5477    /// End-to-end: streaming-encode a hashed message, read the file
5478    /// back as bytes, decode via the buffer-based `tgm_decode`, and
5479    /// confirm the inline-hash slot is surfaced through the
5480    /// `tgm_payload_has_hash` / `tgm_object_hash_*` accessors.
5481    ///
5482    /// As of pass 5 the file-based decode path
5483    /// (`tgm_file_decode_message`) also surfaces inline hashes
5484    /// via a re-read of the raw message bytes — see
5485    /// `ffi_file_decode_surfaces_inline_hash`.
5486    #[test]
5487    fn ffi_streaming_encoder_with_hash() {
5488        let dir = std::env::temp_dir();
5489        let path = dir.join("ffi_streaming_hash.tgm");
5490        let _ = std::fs::remove_file(&path);
5491
5492        let c_path = CString::new(path.to_str().unwrap()).unwrap();
5493        let meta_json = CString::new(r#"{"version":3}"#).unwrap();
5494        let hash_algo = CString::new("xxh3").unwrap();
5495
5496        let mut enc: *mut super::TgmStreamingEncoder = ptr::null_mut();
5497        let err = super::tgm_streaming_encoder_create(
5498            c_path.as_ptr(),
5499            meta_json.as_ptr(),
5500            hash_algo.as_ptr(),
5501            0,
5502            &mut enc,
5503        );
5504        assert!(matches!(err, super::TgmError::Ok));
5505
5506        let values = [1.0f32, 2.0];
5507        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
5508        let desc_json = CString::new(format!(
5509            r#"{{"type":"ntensor","ndim":1,"shape":[{len}],"strides":[1],"dtype":"float32","byte_order":"{bo}","encoding":"none","filter":"none","compression":"none"}}"#,
5510            len = values.len(),
5511            bo = if cfg!(target_endian = "little") { "little" } else { "big" },
5512        )).unwrap();
5513
5514        let err =
5515            super::tgm_streaming_encoder_write(enc, desc_json.as_ptr(), data.as_ptr(), data.len());
5516        assert!(matches!(err, super::TgmError::Ok));
5517
5518        let err = super::tgm_streaming_encoder_finish(enc);
5519        assert!(matches!(err, super::TgmError::Ok));
5520        super::tgm_streaming_encoder_free(enc);
5521
5522        // Read back via the buffer-based decode path so inline
5523        // hashes are extracted.
5524        let bytes = std::fs::read(&path).expect("read file");
5525        let mut msg: *mut super::TgmMessage = ptr::null_mut();
5526        let err = super::tgm_decode(bytes.as_ptr(), bytes.len(), 0, 0, 0, &mut msg);
5527        assert!(matches!(err, super::TgmError::Ok));
5528
5529        assert_eq!(super::tgm_payload_has_hash(msg, 0), 1);
5530        let ht = unsafe { CStr::from_ptr(super::tgm_object_hash_type(msg, 0)) }
5531            .to_str()
5532            .unwrap();
5533        assert_eq!(ht, "xxh3");
5534        let hv = unsafe { CStr::from_ptr(super::tgm_object_hash_value(msg, 0)) }
5535            .to_str()
5536            .unwrap();
5537        assert_eq!(hv.len(), 16, "xxh3 digest is 16 hex chars");
5538
5539        super::tgm_message_free(msg);
5540        let _ = std::fs::remove_file(&path);
5541    }
5542
5543    /// Pass-5 cross-language parity: the file-based decode path
5544    /// (`tgm_file_decode_message`) surfaces the same inline hash
5545    /// as the buffer-based path (`tgm_decode`) for the same
5546    /// underlying bytes.  Pins the symmetry added by the
5547    /// `read_message` + `data_object_inline_hashes` two-step
5548    /// inside the FFI file decoder.
5549    #[test]
5550    fn ffi_file_decode_surfaces_inline_hash() {
5551        // Encode a hashed message into a temp file via the streaming
5552        // encoder, then open it with tgm_file_open + decode via
5553        // tgm_file_decode_message and confirm the hash accessors
5554        // report the same digest that tgm_decode surfaces.
5555        let dir = std::env::temp_dir();
5556        let path = dir.join("ffi_file_hash.tgm");
5557        let _ = std::fs::remove_file(&path);
5558
5559        let c_path = CString::new(path.to_str().unwrap()).unwrap();
5560        let meta_json = CString::new(r#"{"version":3}"#).unwrap();
5561        let hash_algo = CString::new("xxh3").unwrap();
5562        let mut enc: *mut super::TgmStreamingEncoder = ptr::null_mut();
5563        assert!(matches!(
5564            super::tgm_streaming_encoder_create(
5565                c_path.as_ptr(),
5566                meta_json.as_ptr(),
5567                hash_algo.as_ptr(),
5568                0,
5569                &mut enc,
5570            ),
5571            super::TgmError::Ok
5572        ));
5573        let values = [1.0f32, 2.0, 3.0];
5574        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
5575        let desc_json = CString::new(format!(
5576            r#"{{"type":"ntensor","ndim":1,"shape":[{}],"strides":[1],"dtype":"float32","byte_order":"{}","encoding":"none","filter":"none","compression":"none"}}"#,
5577            values.len(),
5578            if cfg!(target_endian = "little") { "little" } else { "big" },
5579        )).unwrap();
5580        assert!(matches!(
5581            super::tgm_streaming_encoder_write(enc, desc_json.as_ptr(), data.as_ptr(), data.len(),),
5582            super::TgmError::Ok
5583        ));
5584        assert!(matches!(
5585            super::tgm_streaming_encoder_finish(enc),
5586            super::TgmError::Ok
5587        ));
5588        super::tgm_streaming_encoder_free(enc);
5589
5590        // File path: tgm_file_open + tgm_file_decode_message.
5591        let mut file: *mut super::TgmFile = ptr::null_mut();
5592        assert!(matches!(
5593            super::tgm_file_open(c_path.as_ptr(), &mut file),
5594            super::TgmError::Ok
5595        ));
5596        let mut file_msg: *mut super::TgmMessage = ptr::null_mut();
5597        assert!(matches!(
5598            super::tgm_file_decode_message(file, 0, 0, 0, 0, &mut file_msg),
5599            super::TgmError::Ok
5600        ));
5601        let file_has = super::tgm_payload_has_hash(file_msg, 0);
5602        let file_hv_ptr = super::tgm_object_hash_value(file_msg, 0);
5603        assert_eq!(file_has, 1);
5604        assert!(!file_hv_ptr.is_null());
5605        let file_hv = unsafe { CStr::from_ptr(file_hv_ptr) }
5606            .to_str()
5607            .unwrap()
5608            .to_string();
5609
5610        // Buffer path: same file, read into memory, tgm_decode.
5611        let bytes = std::fs::read(&path).unwrap();
5612        let mut buf_msg: *mut super::TgmMessage = ptr::null_mut();
5613        assert!(matches!(
5614            super::tgm_decode(bytes.as_ptr(), bytes.len(), 0, 0, 0, &mut buf_msg),
5615            super::TgmError::Ok
5616        ));
5617        let buf_hv_ptr = super::tgm_object_hash_value(buf_msg, 0);
5618        let buf_hv = unsafe { CStr::from_ptr(buf_hv_ptr) }
5619            .to_str()
5620            .unwrap()
5621            .to_string();
5622
5623        assert_eq!(
5624            file_hv, buf_hv,
5625            "file-path and buffer-path hash values must agree"
5626        );
5627        assert_eq!(file_hv.len(), 16, "xxh3 digest is 16 hex chars");
5628
5629        super::tgm_message_free(file_msg);
5630        super::tgm_message_free(buf_msg);
5631        super::tgm_file_close(file);
5632        let _ = std::fs::remove_file(&path);
5633    }
5634
5635    // ── tgm_streaming_encoder_create with invalid hash algo ──
5636
5637    #[test]
5638    fn ffi_streaming_encoder_invalid_hash_algo() {
5639        let dir = std::env::temp_dir();
5640        let path = dir.join("ffi_streaming_bad_hash.tgm");
5641        let c_path = CString::new(path.to_str().unwrap()).unwrap();
5642        let meta_json = CString::new(r#"{"version":3}"#).unwrap();
5643        let bad_algo = CString::new("bogus_hash").unwrap();
5644
5645        let mut enc: *mut super::TgmStreamingEncoder = ptr::null_mut();
5646        let err = super::tgm_streaming_encoder_create(
5647            c_path.as_ptr(),
5648            meta_json.as_ptr(),
5649            bad_algo.as_ptr(),
5650            0,
5651            &mut enc,
5652        );
5653        assert!(matches!(err, super::TgmError::InvalidArg));
5654        let _ = std::fs::remove_file(&path);
5655    }
5656
5657    // ── tgm_streaming_encoder_create with invalid metadata JSON ──
5658
5659    #[test]
5660    fn ffi_streaming_encoder_invalid_metadata() {
5661        let dir = std::env::temp_dir();
5662        let path = dir.join("ffi_streaming_bad_meta.tgm");
5663        let c_path = CString::new(path.to_str().unwrap()).unwrap();
5664        let bad_meta = CString::new("not json").unwrap();
5665
5666        let mut enc: *mut super::TgmStreamingEncoder = ptr::null_mut();
5667        let err = super::tgm_streaming_encoder_create(
5668            c_path.as_ptr(),
5669            bad_meta.as_ptr(),
5670            ptr::null(),
5671            0,
5672            &mut enc,
5673        );
5674        assert!(matches!(err, super::TgmError::Metadata));
5675        let _ = std::fs::remove_file(&path);
5676    }
5677
5678    // ── Multiple objects encode/decode ──
5679
5680    #[test]
5681    fn ffi_encode_decode_multiple_objects() {
5682        let vals1 = [1.0f32, 2.0];
5683        let vals2 = [10.0f32, 20.0, 30.0];
5684        let bo = if cfg!(target_endian = "little") {
5685            "little"
5686        } else {
5687            "big"
5688        };
5689
5690        let json = format!(
5691            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"}}]}}"#,
5692            len1 = vals1.len(),
5693            len2 = vals2.len(),
5694            bo = bo,
5695        );
5696
5697        let data1: Vec<u8> = vals1.iter().flat_map(|v| v.to_ne_bytes()).collect();
5698        let data2: Vec<u8> = vals2.iter().flat_map(|v| v.to_ne_bytes()).collect();
5699
5700        let c_json = CString::new(json).unwrap();
5701        let data_ptrs: [*const u8; 2] = [data1.as_ptr(), data2.as_ptr()];
5702        let data_lens: [usize; 2] = [data1.len(), data2.len()];
5703
5704        let mut out = super::TgmBytes {
5705            data: ptr::null_mut(),
5706            len: 0,
5707        };
5708        let err = super::tgm_encode(
5709            c_json.as_ptr(),
5710            data_ptrs.as_ptr(),
5711            data_lens.as_ptr(),
5712            2,
5713            ptr::null(),
5714            0,
5715            &mut out,
5716        );
5717        assert!(matches!(err, super::TgmError::Ok));
5718
5719        let encoded = unsafe { slice::from_raw_parts(out.data, out.len) }.to_vec();
5720        super::tgm_bytes_free(out);
5721
5722        // Decode all
5723        let mut msg: *mut super::TgmMessage = ptr::null_mut();
5724        let err = super::tgm_decode(encoded.as_ptr(), encoded.len(), 0, 0, 0, &mut msg);
5725        assert!(matches!(err, super::TgmError::Ok));
5726        assert_eq!(super::tgm_message_num_objects(msg), 2);
5727
5728        // Object 0
5729        let mut dl: usize = 0;
5730        let dp = super::tgm_object_data(msg, 0, &mut dl);
5731        let decoded0: Vec<f32> = unsafe { slice::from_raw_parts(dp, dl) }
5732            .chunks_exact(4)
5733            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
5734            .collect();
5735        assert_eq!(decoded0, vals1);
5736
5737        // Object 1
5738        let dp1 = super::tgm_object_data(msg, 1, &mut dl);
5739        let decoded1: Vec<f32> = unsafe { slice::from_raw_parts(dp1, dl) }
5740            .chunks_exact(4)
5741            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
5742            .collect();
5743        assert_eq!(decoded1, vals2);
5744
5745        // Shape check
5746        let shape0 = super::tgm_object_shape(msg, 0);
5747        assert_eq!(unsafe { *shape0 }, vals1.len() as u64);
5748        let shape1 = super::tgm_object_shape(msg, 1);
5749        assert_eq!(unsafe { *shape1 }, vals2.len() as u64);
5750
5751        super::tgm_message_free(msg);
5752    }
5753
5754    // ── tgm_encode with base metadata ──
5755
5756    #[test]
5757    fn ffi_encode_decode_with_base_metadata() {
5758        let bo = if cfg!(target_endian = "little") {
5759            "little"
5760        } else {
5761            "big"
5762        };
5763        let json = format!(
5764            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"}}]}}"#,
5765            bo = bo,
5766        );
5767
5768        let data: Vec<u8> = [1.0f32, 2.0].iter().flat_map(|v| v.to_ne_bytes()).collect();
5769        let c_json = CString::new(json).unwrap();
5770        let data_ptr: *const u8 = data.as_ptr();
5771        let data_len: usize = data.len();
5772
5773        let mut out = super::TgmBytes {
5774            data: ptr::null_mut(),
5775            len: 0,
5776        };
5777        let err = super::tgm_encode(
5778            c_json.as_ptr(),
5779            &data_ptr as *const *const u8,
5780            &data_len as *const usize,
5781            1,
5782            ptr::null(),
5783            0,
5784            &mut out,
5785        );
5786        assert!(matches!(err, super::TgmError::Ok));
5787
5788        let encoded = unsafe { slice::from_raw_parts(out.data, out.len) }.to_vec();
5789        super::tgm_bytes_free(out);
5790
5791        // Decode metadata and check base keys
5792        let mut meta: *mut super::TgmMetadata = ptr::null_mut();
5793        let err = super::tgm_decode_metadata(encoded.as_ptr(), encoded.len(), &mut meta);
5794        assert!(matches!(err, super::TgmError::Ok));
5795
5796        let key = CString::new("param").unwrap();
5797        let val_ptr = super::tgm_metadata_get_string(meta, key.as_ptr());
5798        assert!(!val_ptr.is_null());
5799        let val_str = unsafe { CStr::from_ptr(val_ptr) }.to_str().unwrap();
5800        assert_eq!(val_str, "2t");
5801
5802        let key2 = CString::new("level").unwrap();
5803        let val_ptr2 = super::tgm_metadata_get_string(meta, key2.as_ptr());
5804        assert!(!val_ptr2.is_null());
5805        let val_str2 = unsafe { CStr::from_ptr(val_ptr2) }.to_str().unwrap();
5806        assert_eq!(val_str2, "surface");
5807
5808        super::tgm_metadata_free(meta);
5809    }
5810
5811    // ── tgm_compute_hash deterministic ──
5812
5813    #[test]
5814    fn ffi_compute_hash_deterministic() {
5815        let data = b"deterministic hash test";
5816        let mut out1 = super::TgmBytes {
5817            data: ptr::null_mut(),
5818            len: 0,
5819        };
5820        let mut out2 = super::TgmBytes {
5821            data: ptr::null_mut(),
5822            len: 0,
5823        };
5824
5825        let err = super::tgm_compute_hash(data.as_ptr(), data.len(), ptr::null(), &mut out1);
5826        assert!(matches!(err, super::TgmError::Ok));
5827
5828        let err = super::tgm_compute_hash(data.as_ptr(), data.len(), ptr::null(), &mut out2);
5829        assert!(matches!(err, super::TgmError::Ok));
5830
5831        let hex1 = unsafe { slice::from_raw_parts(out1.data, out1.len) };
5832        let hex2 = unsafe { slice::from_raw_parts(out2.data, out2.len) };
5833        assert_eq!(hex1, hex2);
5834
5835        super::tgm_bytes_free(out1);
5836        super::tgm_bytes_free(out2);
5837    }
5838
5839    // ── tgm_streaming_encoder_write_pre_encoded round-trip ──
5840
5841    #[test]
5842    fn ffi_streaming_encoder_write_pre_encoded_round_trip() {
5843        let dir = std::env::temp_dir();
5844        let path = dir.join("ffi_streaming_pre_encoded.tgm");
5845        let _ = std::fs::remove_file(&path);
5846
5847        let c_path = CString::new(path.to_str().unwrap()).unwrap();
5848        let meta_json = CString::new(r#"{"version":3}"#).unwrap();
5849
5850        let mut enc: *mut super::TgmStreamingEncoder = ptr::null_mut();
5851        let err = super::tgm_streaming_encoder_create(
5852            c_path.as_ptr(),
5853            meta_json.as_ptr(),
5854            ptr::null(),
5855            0,
5856            &mut enc,
5857        );
5858        assert!(matches!(err, super::TgmError::Ok));
5859
5860        let values = [7.0f32, 8.0];
5861        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
5862        let desc_json = CString::new(format!(
5863            r#"{{"type":"ntensor","ndim":1,"shape":[{len}],"strides":[1],"dtype":"float32","byte_order":"{bo}","encoding":"none","filter":"none","compression":"none"}}"#,
5864            len = values.len(),
5865            bo = if cfg!(target_endian = "little") { "little" } else { "big" },
5866        )).unwrap();
5867
5868        let err = super::tgm_streaming_encoder_write_pre_encoded(
5869            enc,
5870            desc_json.as_ptr(),
5871            data.as_ptr(),
5872            data.len(),
5873        );
5874        assert!(matches!(err, super::TgmError::Ok));
5875
5876        let err = super::tgm_streaming_encoder_finish(enc);
5877        assert!(matches!(err, super::TgmError::Ok));
5878        super::tgm_streaming_encoder_free(enc);
5879
5880        // Read back and verify
5881        let mut file: *mut super::TgmFile = ptr::null_mut();
5882        let err = super::tgm_file_open(c_path.as_ptr(), &mut file);
5883        assert!(matches!(err, super::TgmError::Ok));
5884
5885        let mut msg: *mut super::TgmMessage = ptr::null_mut();
5886        let err = super::tgm_file_decode_message(file, 0, 0, 0, 0, &mut msg);
5887        assert!(matches!(err, super::TgmError::Ok));
5888
5889        let mut dl: usize = 0;
5890        let dp = super::tgm_object_data(msg, 0, &mut dl);
5891        let decoded: Vec<f32> = unsafe { slice::from_raw_parts(dp, dl) }
5892            .chunks_exact(4)
5893            .map(|c| f32::from_ne_bytes(c.try_into().unwrap()))
5894            .collect();
5895        assert_eq!(decoded, values);
5896
5897        super::tgm_message_free(msg);
5898        super::tgm_file_close(file);
5899        let _ = std::fs::remove_file(&path);
5900    }
5901
5902    // ── tgm_encode with invalid hash algo ──
5903
5904    #[test]
5905    fn ffi_encode_invalid_hash_algo() {
5906        let json = CString::new(r#"{"version":3,"descriptors":[]}"#).unwrap();
5907        let bad_algo = CString::new("bogus").unwrap();
5908        let mut out = super::TgmBytes {
5909            data: ptr::null_mut(),
5910            len: 0,
5911        };
5912
5913        let err = super::tgm_encode(
5914            json.as_ptr(),
5915            ptr::null(),
5916            ptr::null(),
5917            0,
5918            bad_algo.as_ptr(),
5919            0,
5920            &mut out,
5921        );
5922        assert!(matches!(err, super::TgmError::InvalidArg));
5923    }
5924
5925    // ── tgm_scan_entry OOB returns sentinel and sets error ──
5926
5927    #[test]
5928    fn ffi_scan_entry_oob_returns_sentinel() {
5929        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
5930
5931        let mut result: *mut super::TgmScanResult = ptr::null_mut();
5932        let err = super::tgm_scan(encoded.as_ptr(), encoded.len(), &mut result);
5933        assert!(matches!(err, super::TgmError::Ok));
5934
5935        assert_eq!(super::tgm_scan_count(result), 1);
5936
5937        // Valid index works
5938        let good = super::tgm_scan_entry(result, 0);
5939        assert_eq!(good.offset, 0);
5940        assert!(good.length > 0);
5941
5942        // OOB index returns sentinel
5943        let bad = super::tgm_scan_entry(result, 1);
5944        assert_eq!(bad.offset, usize::MAX);
5945        assert_eq!(bad.length, 0);
5946
5947        // Error message is set
5948        let err_ptr = super::tgm_last_error();
5949        assert!(!err_ptr.is_null());
5950        let err_str = unsafe { CStr::from_ptr(err_ptr) }.to_str().unwrap();
5951        assert!(
5952            err_str.contains("out of range"),
5953            "expected OOB error, got: {err_str}"
5954        );
5955
5956        super::tgm_scan_free(result);
5957    }
5958
5959    // ── collect_data_slices null ptr + len=0 safety ──
5960
5961    #[test]
5962    fn ffi_encode_zero_length_null_data_accepted() {
5963        // Encode with a zero-element tensor where the data pointer could be null
5964        // but length is 0 — should succeed without UB.
5965        let json = CString::new(
5966            r#"{"version":3,"descriptors":[{"type":"ntensor","ndim":1,"shape":[0],"strides":[1],"dtype":"float32","byte_order":"little","encoding":"none","filter":"none","compression":"none"}]}"#,
5967        )
5968        .unwrap();
5969        let data_ptrs: [*const u8; 1] = [ptr::null()];
5970        let data_lens: [usize; 1] = [0];
5971        let mut out = super::TgmBytes {
5972            data: ptr::null_mut(),
5973            len: 0,
5974        };
5975
5976        let err = super::tgm_encode(
5977            json.as_ptr(),
5978            data_ptrs.as_ptr(),
5979            data_lens.as_ptr(),
5980            1,
5981            ptr::null(), // no hash
5982            0,           // threads
5983            &mut out,
5984        );
5985        assert!(
5986            matches!(err, super::TgmError::Ok),
5987            "encoding zero-length data with null pointer should succeed"
5988        );
5989        if !out.data.is_null() {
5990            super::tgm_bytes_free(out);
5991        }
5992    }
5993
5994    // ═══ Coverage-closer tests ═════════════════════════════════════════
5995
5996    // ── tgm_validate (previously zero tests) ───────────────────────────
5997
5998    #[test]
5999    fn ffi_validate_null_out() {
6000        let err = super::tgm_validate(ptr::null(), 0, ptr::null(), 0, ptr::null_mut());
6001        assert!(matches!(err, super::TgmError::InvalidArg));
6002    }
6003
6004    #[test]
6005    fn ffi_validate_null_buf_with_nonzero_len() {
6006        let mut out = super::TgmBytes {
6007            data: ptr::null_mut(),
6008            len: 0,
6009        };
6010        let err = super::tgm_validate(ptr::null(), 42, ptr::null(), 0, &mut out);
6011        assert!(matches!(err, super::TgmError::InvalidArg));
6012    }
6013
6014    #[test]
6015    fn ffi_validate_empty_buffer_ok() {
6016        // buf=null, len=0 → valid empty-buffer validation
6017        let mut out = super::TgmBytes {
6018            data: ptr::null_mut(),
6019            len: 0,
6020        };
6021        let err = super::tgm_validate(ptr::null(), 0, ptr::null(), 0, &mut out);
6022        assert!(matches!(err, super::TgmError::Ok));
6023        assert!(!out.data.is_null());
6024        assert!(out.len > 0);
6025        super::tgm_bytes_free(out);
6026    }
6027
6028    #[test]
6029    fn ffi_validate_valid_message_all_levels() {
6030        let encoded = ffi_encode_single_f32_tensor(&[1.0f32, 2.0, 3.0, 4.0], "");
6031        for level_str in &["quick", "checksum", "default", "full"] {
6032            let level = CString::new(*level_str).unwrap();
6033            let mut out = super::TgmBytes {
6034                data: ptr::null_mut(),
6035                len: 0,
6036            };
6037            let err =
6038                super::tgm_validate(encoded.as_ptr(), encoded.len(), level.as_ptr(), 0, &mut out);
6039            assert!(matches!(err, super::TgmError::Ok), "level {level_str}");
6040            super::tgm_bytes_free(out);
6041        }
6042    }
6043
6044    #[test]
6045    fn ffi_validate_canonical_flag() {
6046        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
6047        let mut out = super::TgmBytes {
6048            data: ptr::null_mut(),
6049            len: 0,
6050        };
6051        let err = super::tgm_validate(
6052            encoded.as_ptr(),
6053            encoded.len(),
6054            ptr::null(),
6055            1, // check_canonical
6056            &mut out,
6057        );
6058        assert!(matches!(err, super::TgmError::Ok));
6059        super::tgm_bytes_free(out);
6060    }
6061
6062    #[test]
6063    fn ffi_validate_invalid_level_string() {
6064        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
6065        let bogus = CString::new("bogus-level-name").unwrap();
6066        let mut out = super::TgmBytes {
6067            data: ptr::null_mut(),
6068            len: 0,
6069        };
6070        let err = super::tgm_validate(encoded.as_ptr(), encoded.len(), bogus.as_ptr(), 0, &mut out);
6071        assert!(matches!(err, super::TgmError::InvalidArg));
6072    }
6073
6074    #[test]
6075    fn ffi_validate_garbage_reports_issues() {
6076        let garbage = [0xDEu8; 100];
6077        let mut out = super::TgmBytes {
6078            data: ptr::null_mut(),
6079            len: 0,
6080        };
6081        let err = super::tgm_validate(garbage.as_ptr(), garbage.len(), ptr::null(), 0, &mut out);
6082        assert!(matches!(err, super::TgmError::Ok));
6083        let json = unsafe { slice::from_raw_parts(out.data, out.len) };
6084        let s = std::str::from_utf8(json).unwrap();
6085        assert!(s.contains("issues"));
6086        super::tgm_bytes_free(out);
6087    }
6088
6089    // ── tgm_validate_file (previously zero tests) ──────────────────────
6090
6091    #[test]
6092    fn ffi_validate_file_null_args() {
6093        let mut out = super::TgmBytes {
6094            data: ptr::null_mut(),
6095            len: 0,
6096        };
6097        let err = super::tgm_validate_file(ptr::null(), ptr::null(), 0, &mut out);
6098        assert!(matches!(err, super::TgmError::InvalidArg));
6099        let path = CString::new("/tmp/x.tgm").unwrap();
6100        let err = super::tgm_validate_file(path.as_ptr(), ptr::null(), 0, ptr::null_mut());
6101        assert!(matches!(err, super::TgmError::InvalidArg));
6102    }
6103
6104    #[test]
6105    fn ffi_validate_file_nonexistent() {
6106        let path = CString::new("/nonexistent/path/to/missing-file.tgm").unwrap();
6107        let mut out = super::TgmBytes {
6108            data: ptr::null_mut(),
6109            len: 0,
6110        };
6111        let err = super::tgm_validate_file(path.as_ptr(), ptr::null(), 0, &mut out);
6112        assert!(matches!(err, super::TgmError::Io));
6113    }
6114
6115    #[test]
6116    fn ffi_validate_file_valid_round_trip() {
6117        use std::io::Write;
6118        let encoded = ffi_encode_single_f32_tensor(&[1.0f32, 2.0, 3.0], "");
6119        let tmp = std::env::temp_dir().join(format!(
6120            "tensogram-ffi-validate-file-{}.tgm",
6121            std::process::id(),
6122        ));
6123        std::fs::File::create(&tmp)
6124            .unwrap()
6125            .write_all(&encoded)
6126            .unwrap();
6127        let path = CString::new(tmp.to_str().unwrap()).unwrap();
6128        let mut out = super::TgmBytes {
6129            data: ptr::null_mut(),
6130            len: 0,
6131        };
6132        let err = super::tgm_validate_file(path.as_ptr(), ptr::null(), 0, &mut out);
6133        assert!(matches!(err, super::TgmError::Ok));
6134        super::tgm_bytes_free(out);
6135        let _ = std::fs::remove_file(&tmp);
6136    }
6137
6138    #[test]
6139    fn ffi_validate_file_invalid_level() {
6140        let path = CString::new("/tmp/dummy.tgm").unwrap();
6141        let level = CString::new("bogus").unwrap();
6142        let mut out = super::TgmBytes {
6143            data: ptr::null_mut(),
6144            len: 0,
6145        };
6146        let err = super::tgm_validate_file(path.as_ptr(), level.as_ptr(), 0, &mut out);
6147        assert!(matches!(err, super::TgmError::InvalidArg));
6148    }
6149
6150    // ── Inline-hash integrity via tgm_validate on tampered payload ──
6151
6152    /// Flipping a byte inside the payload region of a hashed
6153    /// message must surface a hash mismatch through
6154    /// `tgm_validate` at the `integrity` / `checksum` level (the
6155    /// v3 integrity channel — see `plans/WIRE_FORMAT.md` §11).
6156    ///
6157    /// `tgm_decode` in v3 does **not** verify frame-level hashes
6158    /// (decode is a pure deserialisation path); the `verify_hash`
6159    /// flag on decode is retained for source compatibility but is
6160    /// a no-op.  Hash integrity is always a validate-level check.
6161    #[test]
6162    fn ffi_validate_detects_tampered_payload() {
6163        let values = vec![1.0f32; 256];
6164        let encoded = ffi_encode_with_hash(&values);
6165        let mut tampered = encoded.clone();
6166        // Tamper ~75% into the message so we're in the payload
6167        // region, not the frame header / CBOR descriptor.
6168        let pos = (tampered.len() * 75) / 100;
6169        tampered[pos] ^= 0xFF;
6170        tampered[pos + 1] ^= 0xFF;
6171
6172        let mut out = super::TgmBytes {
6173            data: ptr::null_mut(),
6174            len: 0,
6175        };
6176        let level = CString::new("checksum").unwrap();
6177        let err = super::tgm_validate(
6178            tampered.as_ptr(),
6179            tampered.len(),
6180            level.as_ptr(),
6181            /* pretty */ 0,
6182            &mut out,
6183        );
6184        // Validation itself runs fine; the report JSON flags the
6185        // mismatch.  `tgm_validate` returns Ok on any parseable
6186        // message and communicates findings via the JSON report.
6187        assert!(matches!(err, super::TgmError::Ok));
6188        assert!(!out.data.is_null());
6189        let json_bytes = unsafe { slice::from_raw_parts(out.data, out.len) };
6190        let json = std::str::from_utf8(json_bytes).unwrap();
6191        assert!(
6192            json.contains("HashMismatch") || json.contains("hash mismatch"),
6193            "expected HashMismatch in validate report, got: {json}"
6194        );
6195        super::tgm_bytes_free(out);
6196    }
6197
6198    // ── tgm_object_data with null out_len pointer ─────────────────────
6199
6200    #[test]
6201    fn ffi_object_data_null_out_len_no_crash() {
6202        let encoded = ffi_encode_single_f32_tensor(&[1.0f32], "");
6203        let mut msg: *mut super::TgmMessage = ptr::null_mut();
6204        let err = super::tgm_decode(
6205            encoded.as_ptr(),
6206            encoded.len(),
6207            /* native_byte_order */ 0,
6208            /* threads */ 0,
6209            0, // verify_hash
6210            &mut msg,
6211        );
6212        assert!(matches!(err, super::TgmError::Ok));
6213        // Calling with null out_len must not crash
6214        let data = super::tgm_object_data(msg, 0, ptr::null_mut());
6215        assert!(!data.is_null());
6216        super::tgm_message_free(msg);
6217    }
6218
6219    // ── tgm_decode_range on a compression that lacks a block index ────
6220
6221    #[test]
6222    fn ffi_decode_range_on_compressed_without_offsets() {
6223        // Encode with zstd compression which doesn't support range decode
6224        // without block offsets.
6225        let json = CString::new(
6226            r#"{"version":3,"descriptors":[{"type":"ntensor","ndim":1,"shape":[100],"strides":[1],"dtype":"float32","byte_order":"little","encoding":"none","filter":"none","compression":"zstd"}]}"#,
6227        )
6228        .unwrap();
6229        let data: Vec<u8> = vec![0u8; 400];
6230        let data_ptr: *const u8 = data.as_ptr();
6231        let data_len = data.len();
6232        let mut out = super::TgmBytes {
6233            data: ptr::null_mut(),
6234            len: 0,
6235        };
6236        let err = super::tgm_encode(
6237            json.as_ptr(),
6238            &data_ptr as *const *const u8,
6239            &data_len as *const usize,
6240            1,
6241            ptr::null(),
6242            /* threads */ 0,
6243            &mut out,
6244        );
6245        assert!(matches!(err, super::TgmError::Ok));
6246        let encoded = unsafe { slice::from_raw_parts(out.data, out.len) }.to_vec();
6247        super::tgm_bytes_free(out);
6248
6249        // Attempt to range-decode: should fail because zstd has no block index.
6250        let range_offset: u64 = 10;
6251        let range_count: u64 = 20;
6252        let mut out_buf = super::TgmBytes {
6253            data: ptr::null_mut(),
6254            len: 0,
6255        };
6256        let mut out_count: usize = 0;
6257        let err = super::tgm_decode_range(
6258            encoded.as_ptr(),
6259            encoded.len(),
6260            0,
6261            &range_offset as *const u64,
6262            &range_count as *const u64,
6263            1,
6264            /* native_byte_order */ 0,
6265            /* threads */ 0,
6266            /* join */ 1,
6267            &mut out_buf,
6268            &mut out_count,
6269        );
6270        assert!(!matches!(err, super::TgmError::Ok));
6271    }
6272
6273    // ── tgm_simple_packing_compute_params edge cases ──
6274
6275    #[test]
6276    fn ffi_simple_packing_null_values() {
6277        let mut ref_val: f64 = 0.0;
6278        let mut bsf: i32 = 0;
6279        let err =
6280            super::tgm_simple_packing_compute_params(ptr::null(), 0, 16, 0, &mut ref_val, &mut bsf);
6281        assert!(matches!(err, super::TgmError::InvalidArg));
6282    }
6283
6284    #[test]
6285    fn ffi_simple_packing_null_out_ref() {
6286        let values: [f64; 3] = [1.0, 2.0, 3.0];
6287        let mut bsf: i32 = 0;
6288        let err = super::tgm_simple_packing_compute_params(
6289            values.as_ptr(),
6290            3,
6291            16,
6292            0,
6293            ptr::null_mut(),
6294            &mut bsf,
6295        );
6296        assert!(matches!(err, super::TgmError::InvalidArg));
6297    }
6298
6299    #[test]
6300    fn ffi_simple_packing_null_out_bsf() {
6301        let values: [f64; 3] = [1.0, 2.0, 3.0];
6302        let mut ref_val: f64 = 0.0;
6303        let err = super::tgm_simple_packing_compute_params(
6304            values.as_ptr(),
6305            3,
6306            16,
6307            0,
6308            &mut ref_val,
6309            ptr::null_mut(),
6310        );
6311        assert!(matches!(err, super::TgmError::InvalidArg));
6312    }
6313
6314    // ── _with_options FFI coverage ─────────────────────────────────────
6315
6316    /// `tgm_encode_with_options(NULL mask_options)` behaves identically
6317    /// to `tgm_encode` — exercises the mask-options NULL-pointer path.
6318    #[test]
6319    fn ffi_encode_with_options_null_mask_ptr() {
6320        let values = [1.0f32, 2.0, 3.0, 4.0];
6321        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
6322        let json = format!(
6323            r#"{{"version":3,"descriptors":[{{"type":"ntensor","ndim":1,"shape":[{}],"strides":[1],"dtype":"float32","byte_order":"{}","encoding":"none","filter":"none","compression":"none"}}]}}"#,
6324            values.len(),
6325            if cfg!(target_endian = "little") {
6326                "little"
6327            } else {
6328                "big"
6329            },
6330        );
6331        let c_json = CString::new(json).unwrap();
6332        let hash_algo = CString::new("xxh3").unwrap();
6333        let data_ptr: *const u8 = data.as_ptr();
6334        let data_len: usize = data.len();
6335
6336        let mut out = super::TgmBytes {
6337            data: ptr::null_mut(),
6338            len: 0,
6339        };
6340        let err = super::tgm_encode_with_options(
6341            c_json.as_ptr(),
6342            &data_ptr as *const *const u8,
6343            &data_len as *const usize,
6344            1,
6345            hash_algo.as_ptr(),
6346            0,           // threads
6347            ptr::null(), // mask_options = NULL → defaults
6348            &mut out,
6349        );
6350        assert!(matches!(err, super::TgmError::Ok));
6351        assert!(!out.data.is_null() && out.len > 0);
6352        super::tgm_bytes_free(out);
6353    }
6354
6355    /// `tgm_decode_with_options(NULL mask_options)` behaves identically
6356    /// to `tgm_decode` — exercises the NULL-pointer branch of
6357    /// `apply_decode_mask_options`.
6358    #[test]
6359    fn ffi_decode_with_options_null_mask_ptr() {
6360        let values = [1.0f32, 2.0];
6361        let encoded = ffi_encode_single_f32_tensor(&values, "");
6362
6363        let mut msg: *mut super::TgmMessage = ptr::null_mut();
6364        let err = super::tgm_decode_with_options(
6365            encoded.as_ptr(),
6366            encoded.len(),
6367            0,
6368            0,
6369            0,           // verify_hash
6370            ptr::null(), // mask_options = NULL → default restore_non_finite=true
6371            &mut msg,
6372        );
6373        assert!(matches!(err, super::TgmError::Ok));
6374        assert!(!msg.is_null());
6375        super::tgm_message_free(msg);
6376    }
6377
6378    /// Non-NULL `TgmDecodeMaskOptions` with `restore_non_finite = false`
6379    /// threads through to `DecodeOptions` — pins the
6380    /// `apply_decode_mask_options` mutation path.
6381    #[test]
6382    fn ffi_decode_with_options_explicit_restore_false() {
6383        let values = [1.0f32, 2.0];
6384        let encoded = ffi_encode_single_f32_tensor(&values, "");
6385
6386        let mask_opts = super::TgmDecodeMaskOptions {
6387            restore_non_finite: false,
6388        };
6389        let mut msg: *mut super::TgmMessage = ptr::null_mut();
6390        let err = super::tgm_decode_with_options(
6391            encoded.as_ptr(),
6392            encoded.len(),
6393            0,
6394            0,
6395            0, // verify_hash
6396            &mask_opts,
6397            &mut msg,
6398        );
6399        assert!(matches!(err, super::TgmError::Ok));
6400        super::tgm_message_free(msg);
6401    }
6402
6403    /// `tgm_decode_with_options` with NULL output pointer must
6404    /// return InvalidArg and set `tgm_last_error`.
6405    #[test]
6406    fn ffi_decode_with_options_null_out() {
6407        let err =
6408            super::tgm_decode_with_options(b"x".as_ptr(), 1, 0, 0, 0, ptr::null(), ptr::null_mut());
6409        assert!(matches!(err, super::TgmError::InvalidArg));
6410        let msg = unsafe { CStr::from_ptr(super::tgm_last_error()) }
6411            .to_str()
6412            .unwrap();
6413        assert!(msg.contains("null"), "expected null-arg msg, got: {msg}");
6414    }
6415
6416    // ── tgm_doctor_to_json ──
6417
6418    #[test]
6419    fn ffi_doctor_to_json_returns_parseable_json() {
6420        let mut out = super::TgmBytes {
6421            data: ptr::null_mut(),
6422            len: 0,
6423        };
6424        let err = super::tgm_doctor_to_json(&mut out);
6425        assert!(matches!(err, super::TgmError::Ok));
6426        assert!(!out.data.is_null());
6427        assert!(out.len > 0);
6428
6429        let json_bytes = unsafe { slice::from_raw_parts(out.data, out.len) };
6430        let json_str = std::str::from_utf8(json_bytes).expect("doctor JSON is UTF-8");
6431        let parsed: serde_json::Value = serde_json::from_str(json_str).expect("doctor JSON parses");
6432
6433        // Schema parity with Python / WASM / Rust core: same three top-level keys.
6434        let obj = parsed.as_object().expect("top-level object");
6435        for key in ["build", "features", "self_test"] {
6436            assert!(obj.contains_key(key), "missing key '{key}' in: {obj:?}");
6437        }
6438
6439        super::tgm_bytes_free(out);
6440    }
6441
6442    #[test]
6443    fn ffi_doctor_to_json_null_out() {
6444        let err = super::tgm_doctor_to_json(ptr::null_mut());
6445        assert!(matches!(err, super::TgmError::InvalidArg));
6446        let msg = unsafe { CStr::from_ptr(super::tgm_last_error()) }
6447            .to_str()
6448            .unwrap();
6449        assert!(msg.contains("null"), "expected null-arg msg, got: {msg}");
6450    }
6451}
6452
6453/// Compute a hash of the given data.
6454/// Returns `TGM_ERROR_OK` on success, fills `out` with a `tgm_bytes_t`
6455/// containing the hex-encoded hash string (NOT null-terminated).
6456/// Free with `tgm_bytes_free`.
6457#[unsafe(no_mangle)]
6458pub extern "C" fn tgm_compute_hash(
6459    data: *const u8,
6460    data_len: usize,
6461    algo: *const c_char,
6462    out: *mut TgmBytes,
6463) -> TgmError {
6464    if data.is_null() || out.is_null() {
6465        set_last_error("null argument");
6466        return TgmError::InvalidArg;
6467    }
6468
6469    // v3 has exactly one algorithm; the FFI accepts NULL, "xxh3" or
6470    // "none" through `parse_hash_algo`.  When the caller passes
6471    // "none" we still compute and return the xxh3 digest because
6472    // `tgm_compute_hash` is the standalone "compute a digest" entry
6473    // point — there is no "no hash" output for it; the only thing
6474    // strict-input gets us is rejecting bogus algorithm names like
6475    // "sha256" with a clear error.
6476    if !algo.is_null() {
6477        let s = match unsafe { CStr::from_ptr(algo) }.to_str() {
6478            Ok(s) => s,
6479            Err(_) => {
6480                set_last_error("invalid UTF-8 in algo");
6481                return TgmError::InvalidArg;
6482            }
6483        };
6484        if let Err(e) = parse_hash_name(Some(s)) {
6485            set_last_error(&e.to_string());
6486            return TgmError::InvalidArg;
6487        }
6488    }
6489
6490    let input = unsafe { slice::from_raw_parts(data, data_len) };
6491    let hex = tensogram::hash::compute_hash(input);
6492    // Rebuild via boxed slice to guarantee capacity == len for tgm_bytes_free.
6493    let mut bytes = hex.into_bytes().into_boxed_slice().into_vec();
6494    let result = TgmBytes {
6495        data: bytes.as_mut_ptr(),
6496        len: bytes.len(),
6497    };
6498    std::mem::forget(bytes);
6499    unsafe {
6500        *out = result;
6501    }
6502    TgmError::Ok
6503}
6504
6505// ---------------------------------------------------------------------------
6506// Doctor: environment diagnostics
6507// ---------------------------------------------------------------------------
6508
6509/// Run environment diagnostics and serialise the report as a JSON byte buffer.
6510///
6511/// The report mirrors `tensogram::doctor::run_diagnostics()` and the
6512/// `tensogram doctor` CLI subcommand.  Cross-language parity: the same
6513/// JSON shape is produced by the Python `tensogram.doctor()` and the
6514/// WASM `doctor()` exports — see `docs/src/cli/doctor.md` for the
6515/// schema.  The C FFI build does **not** run the GRIB or NetCDF
6516/// converter self-tests (those features are CLI-only), so the
6517/// `self_test` array covers only the core encode/decode pipeline plus
6518/// the codecs compiled into the dylib.
6519///
6520/// On success returns `TgmError::Ok` and fills `out` with a JSON
6521/// payload (UTF-8, NOT null-terminated).  Use `tgm_bytes_free` to
6522/// release it.  Callers can safely treat `out.data` as a `char*` of
6523/// length `out.len` and pass it to `json_loads` / `nlohmann::json::parse`
6524/// / equivalent.
6525///
6526/// On serialisation failure returns `TgmError::Encoding` and writes a
6527/// human-readable description retrievable via `tgm_last_error()`.
6528///
6529/// # Example
6530///
6531/// ```c
6532/// tgm_bytes_t report = {0};
6533/// if (tgm_doctor_to_json(&report) == TGM_ERROR_OK) {
6534///     fwrite(report.data, 1, report.len, stdout);
6535///     tgm_bytes_free(report);
6536/// }
6537/// ```
6538#[unsafe(no_mangle)]
6539pub extern "C" fn tgm_doctor_to_json(out: *mut TgmBytes) -> TgmError {
6540    if out.is_null() {
6541        set_last_error("null out pointer");
6542        return TgmError::InvalidArg;
6543    }
6544
6545    let report = tensogram::doctor::run_diagnostics();
6546    let json = match serde_json::to_string(&report) {
6547        Ok(s) => s,
6548        Err(e) => {
6549            set_last_error(&format!("failed to serialise doctor report: {e}"));
6550            return TgmError::Encoding;
6551        }
6552    };
6553
6554    // Rebuild via boxed slice to guarantee capacity == len for tgm_bytes_free.
6555    let mut bytes = json.into_bytes().into_boxed_slice().into_vec();
6556    let result = TgmBytes {
6557        data: bytes.as_mut_ptr(),
6558        len: bytes.len(),
6559    };
6560    std::mem::forget(bytes); // ownership transferred to C
6561    unsafe {
6562        *out = result;
6563    }
6564    TgmError::Ok
6565}
6566
6567// ---------------------------------------------------------------------------
6568// Streaming encoder
6569// ---------------------------------------------------------------------------
6570
6571/// Opaque handle for a streaming encoder that writes data objects progressively.
6572pub struct TgmStreamingEncoder {
6573    inner: Option<StreamingEncoder<std::io::BufWriter<std::fs::File>>>,
6574}
6575
6576/// JSON used for streaming encoder creation — optional extra/base keys.
6577///
6578/// A legacy top-level `"version"` field is tolerated for pre-0.17
6579/// schema compatibility and routed into `_extra_` on encode, matching
6580/// the free-form contract of every other binding.  The wire-format
6581/// version itself lives in the preamble (see
6582/// `plans/WIRE_FORMAT.md` §3).
6583#[derive(serde::Deserialize)]
6584struct StreamingEncodeJson {
6585    #[serde(default)]
6586    version: Option<u16>,
6587    #[serde(default)]
6588    base: Vec<BTreeMap<String, serde_json::Value>>,
6589    #[serde(flatten)]
6590    extra: BTreeMap<String, serde_json::Value>,
6591}
6592
6593/// Parse metadata JSON for the streaming encoder (no "descriptors" key).
6594pub(crate) fn parse_streaming_metadata_json(json_str: &str) -> Result<GlobalMetadata, String> {
6595    let parsed: StreamingEncodeJson = serde_json::from_str(json_str)
6596        .map_err(|e| format!("failed to parse metadata JSON: {e}"))?;
6597
6598    let cbor_base: Vec<BTreeMap<String, ciborium::Value>> = parsed
6599        .base
6600        .into_iter()
6601        .map(|entry| {
6602            entry
6603                .into_iter()
6604                .map(|(k, v)| (k, json_to_cbor(v)))
6605                .collect()
6606        })
6607        .collect();
6608
6609    // Validate: no _reserved_ keys in base entries (library-managed namespace)
6610    for (i, entry) in cbor_base.iter().enumerate() {
6611        if entry.contains_key(RESERVED_KEY) {
6612            return Err(format!(
6613                "base[{i}] must not contain '{RESERVED_KEY}' key — the encoder populates it"
6614            ));
6615        }
6616    }
6617
6618    // Route explicit `_extra_` / legacy `version` under the shared
6619    // free-form merge rule.  Matches the `parse_encode_json` path and
6620    // the Python / TypeScript / Rust-core contract.
6621    let cbor_extra = merge_flattened_extras_with_version(parsed.extra, parsed.version)?;
6622    Ok(GlobalMetadata {
6623        base: cbor_base,
6624        extra: cbor_extra,
6625        ..Default::default()
6626    })
6627}
6628
6629/// Create a streaming encoder writing to a file.
6630///
6631/// `metadata_json` is a free-form JSON object.  The `"descriptors"`
6632/// key is NOT permitted here (objects are supplied one at a time
6633/// via `tgm_streaming_encoder_write`).  A legacy top-level
6634/// `"version"` is tolerated and routed into `_extra_` on encode
6635/// (see `parse_streaming_metadata_json`).
6636///
6637/// `hash_algo`: null-terminated string ("xxh3") or NULL for no hash.
6638///
6639/// On success fills `out` with a `TgmStreamingEncoder` handle.
6640/// Free with `tgm_streaming_encoder_free` or finalize with
6641/// `tgm_streaming_encoder_finish`.
6642#[unsafe(no_mangle)]
6643pub extern "C" fn tgm_streaming_encoder_create(
6644    path: *const c_char,
6645    metadata_json: *const c_char,
6646    hash_algo: *const c_char,
6647    threads: u32,
6648    out: *mut *mut TgmStreamingEncoder,
6649) -> TgmError {
6650    if path.is_null() || metadata_json.is_null() || out.is_null() {
6651        set_last_error("null argument");
6652        return TgmError::InvalidArg;
6653    }
6654
6655    let path_str = match unsafe { CStr::from_ptr(path) }.to_str() {
6656        Ok(s) => s,
6657        Err(e) => {
6658            set_last_error(&format!("invalid UTF-8 in path: {e}"));
6659            return TgmError::InvalidArg;
6660        }
6661    };
6662
6663    let json_str = match unsafe { CStr::from_ptr(metadata_json) }.to_str() {
6664        Ok(s) => s,
6665        Err(e) => {
6666            set_last_error(&format!("invalid UTF-8 in metadata_json: {e}"));
6667            return TgmError::InvalidArg;
6668        }
6669    };
6670
6671    let global_metadata = match parse_streaming_metadata_json(json_str) {
6672        Ok(m) => m,
6673        Err(e) => {
6674            set_last_error(&e);
6675            return TgmError::Metadata;
6676        }
6677    };
6678
6679    let hashing = match parse_hash_algo(hash_algo) {
6680        Ok(b) => b,
6681        Err((code, msg)) => {
6682            set_last_error(&msg);
6683            return code;
6684        }
6685    };
6686
6687    let file = match std::fs::File::create(path_str) {
6688        Ok(f) => f,
6689        Err(e) => {
6690            set_last_error(&e.to_string());
6691            return TgmError::Io;
6692        }
6693    };
6694
6695    let options = EncodeOptions {
6696        hashing,
6697        threads,
6698        ..Default::default()
6699    };
6700    let writer = std::io::BufWriter::new(file);
6701
6702    match StreamingEncoder::new(writer, &global_metadata, &options) {
6703        Ok(enc) => {
6704            let handle = Box::new(TgmStreamingEncoder { inner: Some(enc) });
6705            unsafe {
6706                *out = Box::into_raw(handle);
6707            }
6708            TgmError::Ok
6709        }
6710        Err(e) => {
6711            set_last_error(&e.to_string());
6712            to_error_code(&e)
6713        }
6714    }
6715}
6716
6717/// Write a PrecederMetadata frame for the next data object.
6718///
6719/// `metadata_json` is a JSON object with per-object metadata keys
6720/// (e.g. `{"mars": {"param": "2t"}, "units": "K"}`).  The keys
6721/// become `payload[0]` in a GlobalMetadata CBOR with empty `common`.
6722///
6723/// Must be followed by exactly one `tgm_streaming_encoder_write` call
6724/// before another preceder or `tgm_streaming_encoder_finish`.
6725#[unsafe(no_mangle)]
6726pub extern "C" fn tgm_streaming_encoder_write_preceder(
6727    enc: *mut TgmStreamingEncoder,
6728    metadata_json: *const c_char,
6729) -> TgmError {
6730    if enc.is_null() || metadata_json.is_null() {
6731        set_last_error("null argument");
6732        return TgmError::InvalidArg;
6733    }
6734
6735    let json_str = match unsafe { CStr::from_ptr(metadata_json) }.to_str() {
6736        Ok(s) => s,
6737        Err(e) => {
6738            set_last_error(&format!("invalid UTF-8 in metadata_json: {e}"));
6739            return TgmError::InvalidArg;
6740        }
6741    };
6742
6743    let map: BTreeMap<String, ciborium::Value> =
6744        match serde_json::from_str::<serde_json::Value>(json_str) {
6745            Ok(serde_json::Value::Object(obj)) => {
6746                obj.into_iter().map(|(k, v)| (k, json_to_cbor(v))).collect()
6747            }
6748            Ok(_) => {
6749                set_last_error("metadata_json must be a JSON object");
6750                return TgmError::Metadata;
6751            }
6752            Err(e) => {
6753                set_last_error(&format!("failed to parse metadata JSON: {e}"));
6754                return TgmError::Metadata;
6755            }
6756        };
6757
6758    let encoder = unsafe { &mut *enc };
6759    match encoder.inner.as_mut() {
6760        Some(inner) => match inner.write_preceder(map) {
6761            Ok(()) => TgmError::Ok,
6762            Err(e) => {
6763                set_last_error(&e.to_string());
6764                to_error_code(&e)
6765            }
6766        },
6767        None => {
6768            set_last_error("streaming encoder already finished");
6769            TgmError::InvalidArg
6770        }
6771    }
6772}
6773
6774/// Write a single data object to the streaming encoder.
6775///
6776/// `descriptor_json` is a JSON object with the descriptor fields
6777/// (type, ndim, shape, strides, dtype, byte_order, encoding, filter,
6778/// compression, etc.).
6779#[unsafe(no_mangle)]
6780pub extern "C" fn tgm_streaming_encoder_write(
6781    enc: *mut TgmStreamingEncoder,
6782    descriptor_json: *const c_char,
6783    data: *const u8,
6784    data_len: usize,
6785) -> TgmError {
6786    if enc.is_null() || descriptor_json.is_null() || data.is_null() {
6787        set_last_error("null argument");
6788        return TgmError::InvalidArg;
6789    }
6790
6791    let json_str = match unsafe { CStr::from_ptr(descriptor_json) }.to_str() {
6792        Ok(s) => s,
6793        Err(e) => {
6794            set_last_error(&format!("invalid UTF-8 in descriptor_json: {e}"));
6795            return TgmError::InvalidArg;
6796        }
6797    };
6798
6799    let descriptor: DataObjectDescriptor = match serde_json::from_str(json_str) {
6800        Ok(d) => d,
6801        Err(e) => {
6802            set_last_error(&format!("failed to parse descriptor JSON: {e}"));
6803            return TgmError::Metadata;
6804        }
6805    };
6806
6807    let data_slice = unsafe { slice::from_raw_parts(data, data_len) };
6808    let encoder = unsafe { &mut *enc };
6809
6810    match encoder.inner.as_mut() {
6811        Some(inner) => match inner.write_object(&descriptor, data_slice) {
6812            Ok(()) => TgmError::Ok,
6813            Err(e) => {
6814                set_last_error(&e.to_string());
6815                to_error_code(&e)
6816            }
6817        },
6818        None => {
6819            set_last_error("streaming encoder already finished");
6820            TgmError::InvalidArg
6821        }
6822    }
6823}
6824
6825/// Write a single pre-encoded data object to the streaming encoder.
6826///
6827/// Like `tgm_streaming_encoder_write`, but `data` must already be encoded
6828/// according to the descriptor's pipeline (`encoding` / `filter` /
6829/// `compression`). The library does not run the encoding pipeline — it
6830/// validates the descriptor's pipeline configuration and writes the bytes
6831/// as-is into a data object frame. The hash (if configured on the encoder)
6832/// is recomputed over the caller's bytes.
6833///
6834/// `descriptor_json`: same JSON schema as `tgm_streaming_encoder_write`.
6835///
6836/// For `szip` compression, callers SHOULD include `szip_block_offsets`
6837/// (bit offsets, not byte offsets) in the descriptor's params so that
6838/// `tgm_decode_range` can locate compressed block boundaries later.
6839/// Other pipeline params (e.g. `simple_packing` reference value, scale
6840/// factors) must also be present in the descriptor.
6841///
6842/// Any `hash` field embedded in the descriptor JSON is ignored — the
6843/// library always recomputes the hash from the caller's bytes.
6844#[unsafe(no_mangle)]
6845pub extern "C" fn tgm_streaming_encoder_write_pre_encoded(
6846    enc: *mut TgmStreamingEncoder,
6847    descriptor_json: *const c_char,
6848    data: *const u8,
6849    data_len: usize,
6850) -> TgmError {
6851    if enc.is_null() || descriptor_json.is_null() || data.is_null() {
6852        set_last_error("null argument");
6853        return TgmError::InvalidArg;
6854    }
6855
6856    let json_str = match unsafe { CStr::from_ptr(descriptor_json) }.to_str() {
6857        Ok(s) => s,
6858        Err(e) => {
6859            set_last_error(&format!("invalid UTF-8 in descriptor_json: {e}"));
6860            return TgmError::InvalidArg;
6861        }
6862    };
6863
6864    let descriptor: DataObjectDescriptor = match serde_json::from_str(json_str) {
6865        Ok(d) => d,
6866        Err(e) => {
6867            set_last_error(&format!("failed to parse descriptor JSON: {e}"));
6868            return TgmError::Metadata;
6869        }
6870    };
6871
6872    let data_slice = unsafe { slice::from_raw_parts(data, data_len) };
6873    let encoder = unsafe { &mut *enc };
6874
6875    match encoder.inner.as_mut() {
6876        Some(inner) => match inner.write_object_pre_encoded(&descriptor, data_slice) {
6877            Ok(()) => TgmError::Ok,
6878            Err(e) => {
6879                set_last_error(&e.to_string());
6880                to_error_code(&e)
6881            }
6882        },
6883        None => {
6884            set_last_error("streaming encoder already finished");
6885            TgmError::InvalidArg
6886        }
6887    }
6888}
6889
6890/// Return the number of objects written so far.
6891#[unsafe(no_mangle)]
6892pub extern "C" fn tgm_streaming_encoder_count(enc: *const TgmStreamingEncoder) -> usize {
6893    if enc.is_null() {
6894        return 0;
6895    }
6896    unsafe { (*enc).inner.as_ref().map(|e| e.object_count()).unwrap_or(0) }
6897}
6898
6899/// Finalize the streaming encoder, writing footer and closing the file.
6900///
6901/// After calling this, the handle is still valid but empty — the caller
6902/// must still call `tgm_streaming_encoder_free` to release it.
6903#[unsafe(no_mangle)]
6904pub extern "C" fn tgm_streaming_encoder_finish(enc: *mut TgmStreamingEncoder) -> TgmError {
6905    if enc.is_null() {
6906        set_last_error("null argument");
6907        return TgmError::InvalidArg;
6908    }
6909
6910    let encoder = unsafe { &mut *enc };
6911    match encoder.inner.take() {
6912        Some(inner) => match inner.finish() {
6913            Ok(_writer) => {
6914                // Writer is dropped, file is closed.
6915                // Do NOT free enc — caller must call tgm_streaming_encoder_free.
6916                TgmError::Ok
6917            }
6918            Err(e) => {
6919                set_last_error(&e.to_string());
6920                to_error_code(&e)
6921            }
6922        },
6923        None => {
6924            set_last_error("streaming encoder already finished");
6925            TgmError::InvalidArg
6926        }
6927    }
6928}
6929
6930/// Free a streaming encoder without finalizing (abandons the output).
6931#[unsafe(no_mangle)]
6932pub extern "C" fn tgm_streaming_encoder_free(enc: *mut TgmStreamingEncoder) {
6933    if !enc.is_null() {
6934        unsafe {
6935            drop(Box::from_raw(enc));
6936        }
6937    }
6938}
6939
6940// ---------------------------------------------------------------------------
6941// Validation
6942// ---------------------------------------------------------------------------
6943
6944/// Parse a C-string validation level into `ValidateOptions`.
6945fn parse_validate_options(
6946    level: *const c_char,
6947    check_canonical: i32,
6948) -> Result<ValidateOptions, (TgmError, String)> {
6949    let level_str = if level.is_null() {
6950        "default"
6951    } else {
6952        unsafe { CStr::from_ptr(level) }
6953            .to_str()
6954            .map_err(|_| (TgmError::InvalidArg, "invalid UTF-8 in level".to_string()))?
6955    };
6956
6957    let (max_level, checksum_only) = match level_str {
6958        "quick" => (ValidationLevel::Structure, false),
6959        "default" => (ValidationLevel::Integrity, false),
6960        "checksum" => (ValidationLevel::Integrity, true),
6961        "full" => (ValidationLevel::Fidelity, false),
6962        other => {
6963            return Err((
6964                TgmError::InvalidArg,
6965                format!(
6966                    "unknown validation level: '{}', expected one of: quick, default, checksum, full",
6967                    other
6968                ),
6969            ));
6970        }
6971    };
6972
6973    Ok(ValidateOptions {
6974        max_level,
6975        check_canonical: check_canonical != 0,
6976        checksum_only,
6977    })
6978}
6979
6980/// Validate a single Tensogram message buffer.
6981///
6982/// `buf` / `buf_len`: the wire-format message bytes (single message).
6983///   `buf` may be NULL when `buf_len` is 0 (empty-buffer validation).
6984/// `level`: validation depth — null-terminated C string:
6985///   `"quick"` (structure only), `"default"` (up to hash check),
6986///   `"checksum"` (hash check, suppress structural warnings),
6987///   `"full"` (full decode + NaN/Inf scan). NULL defaults to `"default"`.
6988/// `check_canonical`: non-zero to check RFC 8949 CBOR key ordering.
6989/// `out`: receives UTF-8 JSON bytes describing the validation report.
6990///   Not NUL-terminated — use `out->len` for the byte count.
6991///   Free with `tgm_bytes_free`.
6992///
6993/// Returns `TGM_ERROR_OK` on success (even if the message has issues —
6994/// the issues are in the JSON report). Returns `TGM_ERROR_INVALID_ARG`
6995/// for argument validation failures (null pointers, invalid level string),
6996/// or `TGM_ERROR_ENCODING` if JSON serialization of the report fails.
6997#[unsafe(no_mangle)]
6998pub extern "C" fn tgm_validate(
6999    buf: *const u8,
7000    buf_len: usize,
7001    level: *const c_char,
7002    check_canonical: i32,
7003    out: *mut TgmBytes,
7004) -> TgmError {
7005    if out.is_null() {
7006        set_last_error("null argument");
7007        return TgmError::InvalidArg;
7008    }
7009    // Allow buf=NULL when buf_len=0 (empty-buffer validation).
7010    if buf.is_null() && buf_len > 0 {
7011        set_last_error("null buf with non-zero buf_len");
7012        return TgmError::InvalidArg;
7013    }
7014
7015    let options = match parse_validate_options(level, check_canonical) {
7016        Ok(o) => o,
7017        Err((code, msg)) => {
7018            set_last_error(&msg);
7019            return code;
7020        }
7021    };
7022
7023    let data = if buf.is_null() {
7024        &[]
7025    } else {
7026        unsafe { slice::from_raw_parts(buf, buf_len) }
7027    };
7028    let report = validate_message(data, &options);
7029
7030    match serde_json::to_vec(&report) {
7031        Ok(json_bytes) => {
7032            let mut json_bytes = json_bytes.into_boxed_slice().into_vec();
7033            let result = TgmBytes {
7034                data: json_bytes.as_mut_ptr(),
7035                len: json_bytes.len(),
7036            };
7037            std::mem::forget(json_bytes);
7038            unsafe {
7039                *out = result;
7040            }
7041            TgmError::Ok
7042        }
7043        Err(e) => {
7044            set_last_error(&format!("JSON serialization failed: {e}"));
7045            TgmError::Encoding
7046        }
7047    }
7048}
7049
7050/// Validate all messages in a `.tgm` file.
7051///
7052/// `path`: null-terminated UTF-8 path to the file.
7053/// `level`: validation depth (same as `tgm_validate`). NULL = `"default"`.
7054/// `check_canonical`: non-zero to check CBOR key ordering.
7055/// `out`: receives UTF-8 JSON bytes describing the file validation report.
7056///   Not NUL-terminated — use `out->len` for the byte count.
7057///   Free with `tgm_bytes_free`.
7058///
7059/// Returns `TGM_ERROR_OK` on success (issues are in the JSON).
7060/// Returns `TGM_ERROR_IO` if the file cannot be opened or read.
7061/// Returns `TGM_ERROR_INVALID_ARG` for null pointers or invalid level.
7062/// Returns `TGM_ERROR_ENCODING` if JSON serialization of the report fails.
7063#[unsafe(no_mangle)]
7064pub extern "C" fn tgm_validate_file(
7065    path: *const c_char,
7066    level: *const c_char,
7067    check_canonical: i32,
7068    out: *mut TgmBytes,
7069) -> TgmError {
7070    if path.is_null() || out.is_null() {
7071        set_last_error("null argument");
7072        return TgmError::InvalidArg;
7073    }
7074
7075    let path_str = match unsafe { CStr::from_ptr(path) }.to_str() {
7076        Ok(s) => s,
7077        Err(e) => {
7078            set_last_error(&format!("invalid UTF-8 in path: {e}"));
7079            return TgmError::InvalidArg;
7080        }
7081    };
7082
7083    let options = match parse_validate_options(level, check_canonical) {
7084        Ok(o) => o,
7085        Err((code, msg)) => {
7086            set_last_error(&msg);
7087            return code;
7088        }
7089    };
7090
7091    let report = match core_validate_file(Path::new(path_str), &options) {
7092        Ok(r) => r,
7093        Err(e) => {
7094            set_last_error(&e.to_string());
7095            return TgmError::Io;
7096        }
7097    };
7098
7099    match serde_json::to_vec(&report) {
7100        Ok(json_bytes) => {
7101            let mut json_bytes = json_bytes.into_boxed_slice().into_vec();
7102            let result = TgmBytes {
7103                data: json_bytes.as_mut_ptr(),
7104                len: json_bytes.len(),
7105            };
7106            std::mem::forget(json_bytes);
7107            unsafe {
7108                *out = result;
7109            }
7110            TgmError::Ok
7111        }
7112        Err(e) => {
7113            set_last_error(&format!("JSON serialization failed: {e}"));
7114            TgmError::Encoding
7115        }
7116    }
7117}