Skip to main content

shape_vm/executor/control_flow/
foreign_marshal.rs

1//! Shape-value <-> MessagePack marshaling for foreign function calls.
2//!
3//! ADR-006 §2.7.29 W17-foreign-ffi 2026-05-23
4//!
5//! ADR-006 §2.7.4 / §2.7.5 / §2.7.6: this module is the Rust-side carrier
6//! shape for foreign function (extern C / Python / TypeScript) call args
7//! and results, sitting between the byte-level msgpack wire and the
8//! runtime-tier `KindedSlot` carrier.
9//!
10//! Per §2.7.5 the extension contract via `*mut c_void` stays on raw u64
11//! (the `RawCallableInvoker.invoke` signature in `module_exports.rs` is
12//! the stable-ABI surface); the conversion to/from `KindedSlot` happens
13//! **inside shape-vm at this boundary**, not at the extension call
14//! frame.
15//!
16//! W17-foreign-ffi rebuild (v0.3 Round 8, 2026-05-23 — supervisor (iv)
17//! ruling fail-safe FFI version-mismatch refused at extension load time
18//! via `crates/shape-runtime/src/plugins/loader.rs`'s
19//! `shape_abi_version` check; this module's marshal layer trusts the
20//! load gate and treats kind metadata as a §2.7.5 producer-side proof).
21//!
22//! Producer-side proof discipline (§2.7.5):
23//! - **`marshal_args`** reads each input `KindedSlot::kind` as the
24//!   single source of truth for the per-arg dispatch arm; the producer
25//!   stamped the kind at compile time + opcode emission, the deleted
26//!   `tag_bits` dispatch is absent from this boundary.
27//! - **`unmarshal_result`** is the inverse: the declared
28//!   `return_type` string + `schema_id` (registered at compile time)
29//!   are the kind oracles. The msgpack wire bytes are NOT free to
30//!   re-discriminate; the caller's return-type proof selects the
31//!   per-`NativeKind` constructor on the `KindedSlot::from_*` API
32//!   surface (ADR-006 §2.7.6 / Q8 carrier-bound).
33//!
34//! Forbidden patterns refused on sight (CLAUDE.md §Renames-to-refuse-
35//! on-sight + broader-family regex):
36//! - Reframing this marshal layer as a deleted-`tag_bits` dispatch
37//!   reintroduction with any of the §Renames-to-refuse-on-sight
38//!   descriptors — REFUSED. `NativeKind` is the discriminator from
39//!   end to end; the deleted `is_tagged()` probe + the deleted
40//!   ValueWord synthesizer do not appear here.
41//! - Re-introducing `ValueWord` "for the wire" — REFUSED. Wire is
42//!   `rmpv::Value` (an external msgpack model, NOT a deleted internal
43//!   carrier).
44//! - Silent FFI version-mismatch degradation — REFUSED per supervisor
45//!   (iv) ruling. Fail-safe REFUSE LOAD with structured error sits at
46//!   the extension load gate (`shape-runtime/src/plugins/loader.rs`).
47
48use rmpv::Value as Rmp;
49use shape_runtime::type_schema::{FieldType, TypeSchema, TypeSchemaRegistry};
50use shape_value::heap_value::{HeapKind, HeapValue, TypedObjectPtr};
51use shape_value::{KindedSlot, NativeKind, TypedObjectStorage, ValueSlot, VMError};
52use std::sync::Arc;
53
54// ============================================================================
55// Outgoing: KindedSlot args → msgpack bytes
56// ============================================================================
57
58/// Serialize a slice of `KindedSlot` args to msgpack bytes (as an array).
59///
60/// Per-arg dispatch reads `slot.kind()` (NOT slot bits) as the single
61/// source of truth, then routes to the matching `NativeKind` arm in
62/// `kinded_slot_to_msgpack`. Heap-kinded arms dispatch via
63/// `slot.slot().as_heap_value()` (ADR-005 §1 single-discriminator) +
64/// `HeapValue::*` match.
65pub fn marshal_args(
66    args: &[KindedSlot],
67    schemas: &TypeSchemaRegistry,
68) -> Result<Vec<u8>, VMError> {
69    let mut values = Vec::with_capacity(args.len());
70    for arg in args {
71        values.push(kinded_slot_to_msgpack(arg, schemas)?);
72    }
73    let arr = Rmp::Array(values);
74    let mut buf = Vec::new();
75    rmpv::encode::write_value(&mut buf, &arr).map_err(|e| {
76        VMError::RuntimeError(format!("Failed to marshal foreign function args: {}", e))
77    })?;
78    Ok(buf)
79}
80
81/// Project a `KindedSlot` to an `rmpv::Value` per its `NativeKind`.
82///
83/// Scalar arms dispatch on the kind directly; heap arms go through
84/// `slot.slot().as_heap_value() -> &HeapValue` + variant match
85/// (ADR-005 §1). The String / StringV2 / DecimalV2 arms read the
86/// inline carrier directly — `NativeKind` is the discriminator, the
87/// deleted `tag_bits` dispatch has no role here.
88fn kinded_slot_to_msgpack(
89    slot: &KindedSlot,
90    schemas: &TypeSchemaRegistry,
91) -> Result<Rmp, VMError> {
92    let bits = slot.raw();
93    match slot.kind() {
94        // ── Scalar kinds (post-proof per §2.7.5) ───────────────────────
95        NativeKind::Int64 => Ok(Rmp::Integer((bits as i64).into())),
96        NativeKind::Int32 => Ok(Rmp::Integer((bits as i32 as i64).into())),
97        NativeKind::Int16 => Ok(Rmp::Integer((bits as i16 as i64).into())),
98        NativeKind::Int8 => Ok(Rmp::Integer((bits as i8 as i64).into())),
99        NativeKind::UInt64 => Ok(Rmp::Integer((bits).into())),
100        NativeKind::UInt32 => Ok(Rmp::Integer(((bits as u32) as u64).into())),
101        NativeKind::UInt16 => Ok(Rmp::Integer(((bits as u16) as u64).into())),
102        NativeKind::UInt8 => Ok(Rmp::Integer(((bits as u8) as u64).into())),
103        NativeKind::IntSize => Ok(Rmp::Integer((bits as isize as i64).into())),
104        NativeKind::UIntSize => Ok(Rmp::Integer((bits as u64).into())),
105        NativeKind::Float64 => Ok(Rmp::F64(f64::from_bits(bits))),
106        NativeKind::Float32 => Ok(Rmp::F32(f32::from_bits(bits as u32))),
107        NativeKind::Char => {
108            let cp = bits as u32;
109            match char::from_u32(cp) {
110                Some(c) => Ok(Rmp::String(c.to_string().into())),
111                None => Err(VMError::RuntimeError(format!(
112                    "foreign_marshal: invalid char codepoint {cp:#x}"
113                ))),
114            }
115        }
116        NativeKind::Bool => Ok(Rmp::Boolean(bits != 0)),
117        NativeKind::Null => Ok(Rmp::Nil),
118
119        // ── String carriers (legacy Arc<String> + v2-raw StringObj) ────
120        NativeKind::String => {
121            if bits == 0 {
122                return Ok(Rmp::Nil);
123            }
124            // SAFETY: per §2.7.6 String-arm construction contract a kind=
125            // String slot's bits are `Arc::into_raw(Arc<String>)`. The
126            // slot owns one strong-count share for the duration of
127            // `marshal_args`; we borrow the inner `&str` only.
128            let s: &str = unsafe {
129                let arc_ptr = bits as *const String;
130                (*arc_ptr).as_str()
131            };
132            Ok(Rmp::String(s.into()))
133        }
134        NativeKind::StringV2 => {
135            if bits == 0 {
136                return Ok(Rmp::Nil);
137            }
138            // SAFETY: per §2.7.5 amendment Wave 2 Agent B a kind=StringV2
139            // slot's bits are `ptr as u64` where `ptr: *const StringObj`;
140            // `StringObj::as_str` reads the UTF-8 payload directly off
141            // the carrier.
142            let ptr = bits as *const shape_value::v2::string_obj::StringObj;
143            let s = unsafe { shape_value::v2::string_obj::StringObj::as_str(ptr) };
144            Ok(Rmp::String(s.into()))
145        }
146        NativeKind::DecimalV2 => {
147            if bits == 0 {
148                return Ok(Rmp::Nil);
149            }
150            let ptr = bits as *const shape_value::v2::decimal_obj::DecimalObj;
151            let d = unsafe { shape_value::v2::decimal_obj::DecimalObj::value(ptr) };
152            // Decimals on the msgpack wire flow as their string
153            // representation — `rust_decimal::Decimal::to_string` is the
154            // round-trippable canonical form per its docs.
155            Ok(Rmp::String(d.to_string().into()))
156        }
157
158        // ── Nullable kinds: surface-and-stop ──────────────────────────
159        NativeKind::NullableInt64
160        | NativeKind::NullableInt32
161        | NativeKind::NullableInt16
162        | NativeKind::NullableInt8
163        | NativeKind::NullableUInt64
164        | NativeKind::NullableUInt32
165        | NativeKind::NullableUInt16
166        | NativeKind::NullableUInt8
167        | NativeKind::NullableIntSize
168        | NativeKind::NullableUIntSize
169        | NativeKind::NullableFloat64 => Err(VMError::NotImplemented(format!(
170            "foreign_marshal: Nullable scalar kind {:?} has no FFI wire \
171             projection yet (W17-foreign-ffi follow-up — same sentinel-rule \
172             gap as snapshot W17-snapshot-nullable).",
173            slot.kind()
174        ))),
175
176        // ── Heap kinds: dispatch via HeapValue (ADR-005 §1) ────────────
177        NativeKind::Ptr(heap_kind) => heap_slot_to_msgpack(bits, heap_kind, schemas),
178    }
179}
180
181/// Project a heap-kinded slot to its msgpack representation.
182///
183/// Per the 5-arm receiver-recovery rule (CLAUDE.md): the slot bits for
184/// `kind=Ptr(HeapKind::X)` are NOT a `*const HeapValue` for the typed-
185/// pointer variants. The String / Decimal / BigInt / TypedObject /
186/// HashMap arms reconstruct the typed `Arc<T>` (or `TypedObjectPtr` for
187/// the v2-raw carrier), peek at the payload, then restore the share.
188/// The remaining heap variants reach this point only through the
189/// "boxed via `ValueSlot::from_heap(HeapValue)`" path (slot bits =
190/// `Box::into_raw(Box<HeapValue>)`) and route through `as_heap_value()`.
191fn heap_slot_to_msgpack(
192    bits: u64,
193    heap_kind: HeapKind,
194    schemas: &TypeSchemaRegistry,
195) -> Result<Rmp, VMError> {
196    if bits == 0 {
197        return Ok(Rmp::Nil);
198    }
199    match heap_kind {
200        HeapKind::String => unsafe {
201            // SAFETY: bits = Arc::into_raw(Arc<String>) per
202            // ValueSlot::from_string_arc.
203            let arc = Arc::<String>::from_raw(bits as *const String);
204            let s = (*arc).clone();
205            let _ = Arc::into_raw(arc); // restore the original share
206            Ok(Rmp::String(s.into()))
207        },
208        HeapKind::BigInt => unsafe {
209            let arc = Arc::<i64>::from_raw(bits as *const i64);
210            let v = *arc;
211            let _ = Arc::into_raw(arc);
212            Ok(Rmp::Integer(v.into()))
213        },
214        HeapKind::Decimal => unsafe {
215            let arc =
216                Arc::<rust_decimal::Decimal>::from_raw(bits as *const rust_decimal::Decimal);
217            let v = *arc;
218            let _ = Arc::into_raw(arc);
219            Ok(Rmp::String(v.to_string().into()))
220        },
221        HeapKind::Char => {
222            // Char is an inline scalar in the HeapKind::Char arm —
223            // bits encode the u32 codepoint per §2.7.5 amendment.
224            let cp = bits as u32;
225            match char::from_u32(cp) {
226                Some(c) => Ok(Rmp::String(c.to_string().into())),
227                None => Err(VMError::RuntimeError(format!(
228                    "foreign_marshal: HeapKind::Char invalid codepoint {cp:#x}"
229                ))),
230            }
231        }
232        HeapKind::TypedObject => {
233            // Per the §2.3 amendment + Wave 2 D4 ckpt-final-prime² the
234            // TypedObject slot bits are a raw `*const TypedObjectStorage`
235            // produced by `TypedObjectStorage::_new` (v2-raw); refcount
236            // discipline goes through `v2_retain` / `v2_release` on the
237            // on-header refcount at offset 0. We treat the slot as
238            // BORROWED for the marshal read (no retain/release pair),
239            // since the slot owns one share for the duration of
240            // `marshal_args`'s borrow of `&[KindedSlot]`.
241            let ptr = bits as *const TypedObjectStorage;
242            let storage: &TypedObjectStorage = unsafe { &*ptr };
243            typed_object_storage_to_msgpack(storage, schemas)
244        }
245        // Other heap kinds: surface-and-stop with structured error.
246        // Per CLAUDE.md the rebuild target lands them per FFI demand,
247        // not all-at-once; the audit explicitly bounds W17-foreign-ffi
248        // to "typed-Arc payloads crossing language boundary" — the
249        // common shapes (String, Decimal, TypedObject, scalar) ship
250        // here; rarer heap kinds (HashMap, HashSet, Deque, Range,
251        // Channel, …) surface for the next round per FFI demand.
252        other => Err(VMError::NotImplemented(format!(
253            "foreign_marshal: HeapKind::{other:?} has no FFI wire \
254             projection yet (W17-foreign-ffi follow-up). The audit \
255             §2.3 bounds the W17 round to typed-Arc payloads; rarer \
256             heap kinds (HashMap, HashSet, …) land per FFI demand."
257        ))),
258    }
259}
260
261/// Project a `TypedObjectStorage` to a msgpack `Map`.
262///
263/// Reads `field_kinds` (the per-slot proven `NativeKind` per §2.7.7 /
264/// Q9 + ADR-006 §2.7.5.1 amendment landed in W17.2-B) when present;
265/// falls back to the schema's `FieldType` projection via
266/// `FieldType::to_native_kind` otherwise (legacy schemas that
267/// pre-date the parallel-kind track). Per-slot raw bits route through
268/// `KindedSlot::new(ValueSlot::from_raw(bits), kind)` so the same
269/// per-kind dispatch ladder handles primitives + heap fields.
270fn typed_object_storage_to_msgpack(
271    storage: &TypedObjectStorage,
272    schemas: &TypeSchemaRegistry,
273) -> Result<Rmp, VMError> {
274    let schema = schemas.get_by_id(storage.schema_id as u32).ok_or_else(|| {
275        VMError::RuntimeError(format!(
276            "foreign_marshal: schema ID {} not found in registry",
277            storage.schema_id
278        ))
279    })?;
280
281    let mut entries = Vec::with_capacity(schema.fields.len());
282    let use_field_kinds = !storage.field_kinds.is_empty();
283    for (i, field) in schema.fields.iter().enumerate() {
284        let slot_bits = storage.slots[i].raw();
285        let kind: NativeKind = if use_field_kinds && i < storage.field_kinds.len() {
286            storage.field_kinds[i]
287        } else {
288            // Legacy schemas that pre-date the parallel-kind track:
289            // project the kind from the schema's FieldType. `Any`/
290            // `Option`/`HashMap`/`Set` refuse static projection per
291            // FieldType::to_native_kind; surface-and-stop here too.
292            field.field_type.to_native_kind().map_err(|e| {
293                VMError::NotImplemented(format!(
294                    "foreign_marshal: schema field '{}' has no static \
295                     NativeKind projection ({e}); legacy schema lacks \
296                     a parallel field-kinds track. W17-foreign-ffi \
297                     follow-up.",
298                    field.name
299                ))
300            })?
301        };
302        // Borrow the slot through KindedSlot — we forget to avoid the
303        // Drop dispatch (no retain happened on read; the storage owns
304        // the share for the duration of this call).
305        let borrowed = KindedSlot::new(ValueSlot::from_raw(slot_bits), kind);
306        let value = kinded_slot_to_msgpack(&borrowed, schemas);
307        std::mem::forget(borrowed);
308        let value = value?;
309        entries.push((Rmp::String(field.wire_name().to_string().into()), value));
310    }
311    Ok(Rmp::Map(entries))
312}
313
314// ============================================================================
315// Incoming: msgpack bytes → KindedSlot
316// ============================================================================
317
318/// Deserialize msgpack bytes to a `KindedSlot` using declared type
319/// information.
320///
321/// Per §2.7.5 producer-side proof: the caller's declared `return_type`
322/// + `schema_id` are the kind oracles; the wire bytes feed values into
323/// the matching `KindedSlot::from_*` constructor (ADR-006 §2.7.6 / Q8
324/// carrier-bound). A wire-vs-declared-type mismatch surfaces as a
325/// structured error rather than a Bool-default fallback (§2.7.5.1
326/// forbidden).
327pub fn unmarshal_result(
328    bytes: &[u8],
329    return_type: &str,
330    schema_id: Option<u32>,
331    schemas: &TypeSchemaRegistry,
332) -> Result<KindedSlot, VMError> {
333    if bytes.is_empty() {
334        return Ok(KindedSlot::none());
335    }
336    let mut cursor = std::io::Cursor::new(bytes);
337    let value: Rmp = rmpv::decode::read_value(&mut cursor).map_err(|e| {
338        VMError::RuntimeError(format!(
339            "Failed to unmarshal foreign function result: {}",
340            e
341        ))
342    })?;
343    let inner_type = strip_result_wrapper(return_type);
344    msgpack_to_kinded_slot(&value, inner_type, schema_id, schemas)
345}
346
347/// Convert an `rmpv::Value` into a `KindedSlot` whose `NativeKind`
348/// matches the declared `target` type.
349fn msgpack_to_kinded_slot(
350    val: &Rmp,
351    target: &str,
352    schema_id: Option<u32>,
353    schemas: &TypeSchemaRegistry,
354) -> Result<KindedSlot, VMError> {
355    // Handle nil first
356    if matches!(val, Rmp::Nil) {
357        if target == "none" || target == "Unit" || target == "()" {
358            return Ok(KindedSlot::none());
359        }
360        return Err(marshal_error(format!("expected {}, got None", target)));
361    }
362
363    match target {
364        "int" | "Int" => match val {
365            Rmp::Integer(i) => Ok(KindedSlot::from_int(
366                i.as_i64()
367                    .or_else(|| i.as_u64().map(|n| n as i64))
368                    .ok_or_else(|| marshal_error("integer out of range"))?,
369            )),
370            _ => Err(marshal_error(format!(
371                "expected int, got {}",
372                msgpack_type_name(val)
373            ))),
374        },
375        "float" | "number" | "Number" | "Float" => match val {
376            Rmp::F64(f) => Ok(KindedSlot::from_number(*f)),
377            Rmp::F32(f) => Ok(KindedSlot::from_number(*f as f64)),
378            Rmp::Integer(i) => {
379                let n = i
380                    .as_i64()
381                    .map(|n| n as f64)
382                    .or_else(|| i.as_u64().map(|n| n as f64))
383                    .ok_or_else(|| marshal_error("integer out of range for float coercion"))?;
384                Ok(KindedSlot::from_number(n))
385            }
386            _ => Err(marshal_error(format!(
387                "expected {}, got {}",
388                target,
389                msgpack_type_name(val)
390            ))),
391        },
392        "string" | "String" => match val {
393            Rmp::String(s) => {
394                let s = s.as_str().ok_or_else(|| {
395                    marshal_error("string contains invalid UTF-8")
396                })?;
397                Ok(KindedSlot::from_string(s))
398            }
399            _ => Err(marshal_error(format!(
400                "expected string, got {}",
401                msgpack_type_name(val)
402            ))),
403        },
404        "bool" | "Bool" => match val {
405            Rmp::Boolean(b) => Ok(KindedSlot::from_bool(*b)),
406            _ => Err(marshal_error(format!(
407                "expected bool, got {}",
408                msgpack_type_name(val)
409            ))),
410        },
411        "none" | "Unit" | "()" => Err(marshal_error(format!(
412            "expected {}, got {}",
413            target,
414            msgpack_type_name(val)
415        ))),
416
417        // Vec<T> / Array<T>
418        s if (s.starts_with("Vec<") || s.starts_with("Array<")) && s.ends_with('>') => {
419            let prefix_len = if s.starts_with("Vec<") { 4 } else { 6 };
420            let _elem_type = &s[prefix_len..s.len() - 1];
421            // Array unmarshal surfaces — building a TypedArray<T> requires
422            // per-element-kind monomorphization (T = f64/i64/string/…)
423            // which is V3-S5 territory (TypedArray rebuild). Surface-and-
424            // stop with a structured error rather than fabricating a
425            // boxed-array carrier.
426            Err(VMError::NotImplemented(format!(
427                "foreign_marshal::unmarshal_result: Array<T> return type \
428                 ({s}) is a V3-S5 follow-up (per-element-kind TypedArray<T> \
429                 monomorphization). Forms with object-of-array shapes \
430                 (TypedObject containing Array<T> fields) similarly defer."
431            )))
432        }
433
434        // Object type literal: {f1: T1, f2: T2, ...}
435        s if s.starts_with('{') && s.ends_with('}') => match val {
436            Rmp::Map(entries) => match schema_id {
437                Some(sid) => marshal_typed_object_from_msgpack(entries, sid, schemas),
438                None => Err(VMError::NotImplemented(format!(
439                    "foreign_marshal: object-typed return ({s}) lacks a \
440                     registered schema_id; ad-hoc field-set inference \
441                     is a follow-up."
442                ))),
443            },
444            _ => Err(marshal_error(format!(
445                "expected object, got {}",
446                msgpack_type_name(val)
447            ))),
448        },
449
450        // Named type with schema_id — marshal as typed object
451        _ if schema_id.is_some() => match val {
452            Rmp::Map(entries) => {
453                marshal_typed_object_from_msgpack(entries, schema_id.unwrap(), schemas)
454            }
455            _ => Err(marshal_error(format!(
456                "expected object for type '{}', got {}",
457                target,
458                msgpack_type_name(val)
459            ))),
460        },
461
462        // No schema, unknown target — surface
463        _ => Err(VMError::NotImplemented(format!(
464            "foreign_marshal::unmarshal_result: return type '{target}' \
465             has no kind oracle (no schema_id, not a primitive). The \
466             §2.7.5 producer-side proof discipline refuses Bool-default \
467             fallback for unknown declared types."
468        ))),
469    }
470}
471
472/// Construct a `KindedSlot` carrying a `HeapValue::TypedObject` from a
473/// msgpack `Map` using a registered schema.
474///
475/// Each field's per-slot `NativeKind` is sourced from the schema's
476/// `FieldType::to_native_kind()` projection (§2.7.5 producer-side
477/// proof). The resulting `KindedSlot` carries
478/// `NativeKind::Ptr(HeapKind::TypedObject)`; the slot bits point at a
479/// fresh `TypedObjectStorage` allocated via `_new` (v2-raw carrier,
480/// refcount initialised to 1).
481fn marshal_typed_object_from_msgpack(
482    entries: &[(Rmp, Rmp)],
483    schema_id: u32,
484    schemas: &TypeSchemaRegistry,
485) -> Result<KindedSlot, VMError> {
486    let schema = schemas.get_by_id(schema_id).ok_or_else(|| {
487        VMError::RuntimeError(format!(
488            "FFI marshal: schema ID {} not found in registry",
489            schema_id
490        ))
491    })?;
492
493    // Build name -> rmpv lookup from the wire map.
494    let mut name_to_value: std::collections::HashMap<&str, &Rmp> =
495        std::collections::HashMap::with_capacity(entries.len());
496    for (k, v) in entries {
497        if let Rmp::String(s) = k {
498            if let Some(name) = s.as_str() {
499                name_to_value.insert(name, v);
500            }
501        }
502    }
503
504    let field_count = schema.fields.len();
505    let mut slots: Vec<ValueSlot> = Vec::with_capacity(field_count);
506    let mut field_kinds: Vec<NativeKind> = Vec::with_capacity(field_count);
507    let mut heap_mask: u64 = 0;
508
509    for (i, field) in schema.fields.iter().enumerate() {
510        let wire_name = field.wire_name();
511        let val = name_to_value.get(wire_name);
512        let (slot, kind) = build_field_slot(val, &field.field_type, &field.name, schemas)?;
513        if is_heap_kind(kind) {
514            heap_mask |= 1u64 << i;
515        }
516        slots.push(slot);
517        field_kinds.push(kind);
518        let _ = i; // silence if unused on width-mask-only paths
519    }
520
521    let ptr = TypedObjectStorage::_new(
522        schema_id as u64,
523        slots.into_boxed_slice(),
524        heap_mask,
525        Arc::from(field_kinds.into_boxed_slice()),
526    );
527    Ok(KindedSlot::new(
528        ValueSlot::from_typed_object_raw(ptr),
529        NativeKind::Ptr(HeapKind::TypedObject),
530    ))
531}
532
533/// Whether a `NativeKind` carries an `Arc`-shaped strong-count share
534/// that participates in the `heap_mask` track. Mirrors the
535/// `typed_object_from_pairs` rule at
536/// `shape-runtime/src/type_schema/mod.rs::typed_object_from_pairs`.
537fn is_heap_kind(kind: NativeKind) -> bool {
538    matches!(
539        kind,
540        NativeKind::String
541            | NativeKind::StringV2
542            | NativeKind::DecimalV2
543            | NativeKind::Ptr(_)
544    )
545}
546
547/// Build a single `ValueSlot` + `NativeKind` pair from a msgpack value
548/// for a known `FieldType`. Heap fields produce a fresh strong-count
549/// share owned by the slot; primitive fields encode bits inline.
550fn build_field_slot(
551    val: Option<&&Rmp>,
552    field_type: &FieldType,
553    field_name: &str,
554    schemas: &TypeSchemaRegistry,
555) -> Result<(ValueSlot, NativeKind), VMError> {
556    match field_type {
557        FieldType::I64 => {
558            let n = val
559                .and_then(|v| match v {
560                    Rmp::Integer(i) => i.as_i64(),
561                    _ => None,
562                })
563                .unwrap_or(0);
564            Ok((ValueSlot::from_int(n), NativeKind::Int64))
565        }
566        FieldType::F64 | FieldType::Decimal => {
567            // Decimal stores as f64 in TypedObject slots per the
568            // existing layout (lossy by design; reconstructed via the
569            // FieldType projection at read time).
570            let f = val
571                .and_then(|v| match v {
572                    Rmp::F64(f) => Some(*f),
573                    Rmp::F32(f) => Some(*f as f64),
574                    Rmp::Integer(i) => i.as_i64().map(|n| n as f64),
575                    Rmp::String(s) => {
576                        // Decimal-as-string round-trip: parse via
577                        // rust_decimal then to_f64. Best-effort.
578                        s.as_str()
579                            .and_then(|s| s.parse::<rust_decimal::Decimal>().ok())
580                            .and_then(|d| {
581                                use rust_decimal::prelude::ToPrimitive;
582                                d.to_f64()
583                            })
584                    }
585                    _ => None,
586                })
587                .unwrap_or(0.0);
588            Ok((ValueSlot::from_number(f), NativeKind::Float64))
589        }
590        FieldType::Bool => {
591            let b = val
592                .and_then(|v| match v {
593                    Rmp::Boolean(b) => Some(*b),
594                    _ => None,
595                })
596                .unwrap_or(false);
597            Ok((ValueSlot::from_bool(b), NativeKind::Bool))
598        }
599        FieldType::String => {
600            let s = val
601                .and_then(|v| match v {
602                    Rmp::String(s) => s.as_str().map(|s| s.to_string()),
603                    _ => None,
604                })
605                .unwrap_or_default();
606            Ok((
607                ValueSlot::from_string_arc(Arc::new(s)),
608                NativeKind::String,
609            ))
610        }
611        FieldType::I8 | FieldType::U8 | FieldType::I16 | FieldType::U16
612        | FieldType::I32 | FieldType::U32 | FieldType::U64
613        | FieldType::Timestamp => {
614            let n = val
615                .and_then(|v| match v {
616                    Rmp::Integer(i) => i.as_i64(),
617                    _ => None,
618                })
619                .unwrap_or(0);
620            let kind = field_type.to_native_kind().map_err(|e| {
621                VMError::RuntimeError(format!(
622                    "foreign_marshal: width-int field '{field_name}': {e}"
623                ))
624            })?;
625            // For width-int, store as i64 bits in the slot (truncation
626            // happens at read time per the existing FieldType layout).
627            Ok((ValueSlot::from_int(n), kind))
628        }
629        FieldType::Object(type_name) => {
630            // Look up nested type's schema; recurse.
631            let nested_schema: TypeSchema = schemas
632                .get(type_name)
633                .ok_or_else(|| {
634                    VMError::RuntimeError(format!(
635                        "foreign_marshal: nested object field '{field_name}': \
636                         type '{type_name}' not in registry"
637                    ))
638                })?
639                .clone();
640            let map_entries = val.and_then(|v| match v {
641                Rmp::Map(m) => Some(m.as_slice()),
642                _ => None,
643            });
644            let nested_slot = match map_entries {
645                Some(entries) => {
646                    marshal_typed_object_from_msgpack(entries, nested_schema.id, schemas)?
647                }
648                None => KindedSlot::none(),
649            };
650            // Move the nested slot's bits into the parent's slot; forget
651            // the KindedSlot wrapper so its Drop doesn't decrement the
652            // share we just transferred into the parent's storage.
653            let bits = nested_slot.raw();
654            let kind = nested_slot.kind();
655            std::mem::forget(nested_slot);
656            Ok((ValueSlot::from_raw(bits), kind))
657        }
658        FieldType::Array(_)
659        | FieldType::Option(_)
660        | FieldType::HashMap { .. }
661        | FieldType::Set(_)
662        | FieldType::Any => Err(VMError::NotImplemented(format!(
663            "foreign_marshal: field '{field_name}' of type {:?} has no \
664             FFI unmarshal projection yet (W17-foreign-ffi follow-up). \
665             Container kinds (Array, HashMap, Set, Option) defer to \
666             V3-S5 / W17.3-4 territory.",
667            field_type
668        ))),
669    }
670}
671
672// ============================================================================
673// Helpers
674// ============================================================================
675
676/// Strip `Result<...>` wrapper from a type string.
677fn strip_result_wrapper(s: &str) -> &str {
678    if s.starts_with("Result<") && s.ends_with('>') {
679        &s[7..s.len() - 1]
680    } else {
681        s
682    }
683}
684
685fn marshal_error(msg: impl Into<String>) -> VMError {
686    VMError::RuntimeError(msg.into())
687}
688
689fn msgpack_type_name(val: &Rmp) -> &'static str {
690    match val {
691        Rmp::Nil => "nil",
692        Rmp::Boolean(_) => "bool",
693        Rmp::Integer(_) => "int",
694        Rmp::F32(_) | Rmp::F64(_) => "float",
695        Rmp::String(_) => "string",
696        Rmp::Array(_) => "array",
697        Rmp::Map(_) => "map",
698        Rmp::Binary(_) => "binary",
699        Rmp::Ext(_, _) => "ext",
700    }
701}
702
703// Suppress unused-import warning when HeapValue / TypedObjectPtr are
704// imported for the §Renames-to-refuse-on-sight refresher — both are
705// kept available for the heap-slot dispatch path even when current
706// arms don't all consume them.
707#[allow(dead_code)]
708fn _unused_imports_keepalive(_hv: &HeapValue, _tp: &TypedObjectPtr) {}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713    use shape_runtime::type_schema::{FieldType, TypeSchemaRegistry};
714
715    #[test]
716    fn marshal_scalar_args_roundtrips_through_msgpack() {
717        let schemas = TypeSchemaRegistry::new();
718        let args = vec![
719            KindedSlot::from_int(42),
720            KindedSlot::from_number(3.14),
721            KindedSlot::from_bool(true),
722            KindedSlot::from_string("hello"),
723        ];
724        let bytes = marshal_args(&args, &schemas).expect("marshal must succeed");
725        let mut cursor = std::io::Cursor::new(bytes.as_slice());
726        let decoded = rmpv::decode::read_value(&mut cursor).expect("valid msgpack");
727        let arr = decoded.as_array().expect("outer array");
728        assert_eq!(arr.len(), 4);
729        assert_eq!(arr[0].as_i64(), Some(42));
730        assert!(matches!(arr[1], Rmp::F64(f) if (f - 3.14).abs() < 1e-9));
731        assert_eq!(arr[2].as_bool(), Some(true));
732        assert_eq!(arr[3].as_str(), Some("hello"));
733    }
734
735    #[test]
736    fn unmarshal_int_result_produces_int64_kind() {
737        let schemas = TypeSchemaRegistry::new();
738        let arr = Rmp::Integer(42i64.into());
739        let mut bytes = Vec::new();
740        rmpv::encode::write_value(&mut bytes, &arr).unwrap();
741        let slot = unmarshal_result(&bytes, "int", None, &schemas).expect("unmarshal");
742        assert_eq!(slot.kind(), NativeKind::Int64);
743        assert_eq!(slot.as_i64(), Some(42));
744    }
745
746    #[test]
747    fn unmarshal_string_result_produces_string_kind() {
748        let schemas = TypeSchemaRegistry::new();
749        let v = Rmp::String("hi".into());
750        let mut bytes = Vec::new();
751        rmpv::encode::write_value(&mut bytes, &v).unwrap();
752        let slot = unmarshal_result(&bytes, "string", None, &schemas).expect("unmarshal");
753        assert_eq!(slot.kind(), NativeKind::String);
754        assert_eq!(slot.as_str(), Some("hi"));
755    }
756
757    #[test]
758    fn unmarshal_result_strips_result_wrapper() {
759        let schemas = TypeSchemaRegistry::new();
760        let v = Rmp::Boolean(true);
761        let mut bytes = Vec::new();
762        rmpv::encode::write_value(&mut bytes, &v).unwrap();
763        let slot = unmarshal_result(&bytes, "Result<bool>", None, &schemas).expect("unmarshal");
764        assert_eq!(slot.kind(), NativeKind::Bool);
765        assert_eq!(slot.as_bool(), Some(true));
766    }
767
768    #[test]
769    fn unmarshal_empty_bytes_returns_none_slot() {
770        let schemas = TypeSchemaRegistry::new();
771        let slot = unmarshal_result(&[], "int", None, &schemas).expect("empty-bytes branch");
772        assert_eq!(slot.kind(), NativeKind::Null);
773    }
774
775    #[test]
776    fn unmarshal_wire_vs_declared_mismatch_surfaces_structured_error() {
777        let schemas = TypeSchemaRegistry::new();
778        let v = Rmp::Boolean(true);
779        let mut bytes = Vec::new();
780        rmpv::encode::write_value(&mut bytes, &v).unwrap();
781        let err = unmarshal_result(&bytes, "int", None, &schemas).unwrap_err();
782        // Structured RuntimeError, not a Bool-default fallback into Int64.
783        assert!(matches!(err, VMError::RuntimeError(_)));
784    }
785}