Skip to main content

scripting/
runtime.rs

1//! Shared wasmtime primitives used by every host of nomiscript-compiled
2//! modules: the entity-script `ScriptExecutor` and the rpc eval channel.
3//!
4//! Owns the engine config (WasmGC + epoch interruption + optional fuel),
5//! the per-bytecode `Module` cache, the trap-classification helper, and the
6//! `decode_eval_result` helper that walks the nomi-eval `(ref null any)`
7//! return value into a structured [`EvalValue`]. Higher-level consumers
8//! parameterize over the Store data type and assemble their own Linker on top.
9
10use std::collections::HashMap;
11use std::sync::{Arc, Mutex};
12
13use thiserror::Error;
14use uuid::Uuid;
15use wasmtime::{
16    AnyRef, AsContextMut, Caller, Config, Engine, FieldType, Linker, Module, Mutability, Rooted,
17    StorageType, Store, StructRef, StructRefPre, StructType, Val, ValType,
18};
19
20#[derive(Debug, Error)]
21pub enum EngineError {
22    #[error("engine config rejected: {0}")]
23    Config(String),
24    #[error("module cache lock poisoned")]
25    CachePoisoned,
26    #[error("module compilation failed: {0}")]
27    Compile(String),
28    #[error("module instantiation failed: {0}")]
29    Instantiate(String),
30    #[error("fuel configuration failed: {0}")]
31    Fuel(String),
32    #[error("missing export `{0}`")]
33    MissingExport(String),
34    #[error("fuel exhausted before completion")]
35    OutOfFuel,
36    #[error("epoch deadline reached before completion")]
37    EpochInterrupt,
38    /// `ConvertCommodity` raises this when no Price row links source
39    /// and target in either direction. Lifted into a dedicated variant
40    /// so clients can prompt the user to add a price row rather than
41    /// guess from a generic trap message.
42    #[error("no conversion: {0}")]
43    NoConversion(String),
44    /// A structured error raised in-guest, surfaced via the `__nomi_raise`
45    /// host fn (`Err(wasmtime::Error::msg("__nomi_raise:CODE:MSG"))`). Two
46    /// sources converge here (ADR-0026): a script `(error 'code "msg")`, and
47    /// an engine error like a commodity mismatch — both `throw $nomi_error`
48    /// in-guest, and the boundary wrapper around each host-invoked body
49    /// catches an uncaught throw and bridges it to `__nomi_raise`. The
50    /// classifier parses the marker prefix and surfaces the code symbol
51    /// (`COMMODITY-MISMATCH`, a script's own symbol, …) onto the wire
52    /// envelope's `:code` slot. Codes are reader-folded (upper-cased)
53    /// symbols, not free-form strings.
54    #[error("script raised {code}: {message}")]
55    ScriptRaised { code: String, message: String },
56    #[error("execution trapped: {0}")]
57    Trap(String),
58}
59
60/// Marker prefix the `__nomi_raise` host fn embeds in its wasmtime
61/// error message so the runtime classifier can recognise script-raised
62/// errors before the unreachable-trap branch fires. Kept here so the
63/// host-fn body and the classifier agree on the wire format.
64pub const NOMI_RAISE_MARKER: &str = "__nomi_raise:";
65
66/// Optional profiling strategy. JitDump is the Linux `perf record`
67/// flow; it writes a `jit-<pid>.dump` file the OS-level profiler can
68/// read. PerfMap is the simpler symbol-name-only Linux variant. Both
69/// require the `wasmtime/profiling` cargo feature, which we pull in
70/// via the `jitdump` feature flag on the scripting crate; non-Linux
71/// builds should leave this as `None`.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub enum ProfilerStrategy {
74    #[default]
75    None,
76    JitDump,
77    PerfMap,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct EngineOpts {
82    pub fuel: bool,
83    pub profiler: ProfilerStrategy,
84}
85
86impl EngineOpts {
87    #[must_use]
88    pub const fn baseline() -> Self {
89        Self {
90            fuel: false,
91            profiler: ProfilerStrategy::None,
92        }
93    }
94
95    #[must_use]
96    pub const fn with_fuel(mut self) -> Self {
97        self.fuel = true;
98        self
99    }
100
101    #[must_use]
102    pub const fn with_profiler(mut self, strategy: ProfilerStrategy) -> Self {
103        self.profiler = strategy;
104        self
105    }
106}
107
108impl Default for EngineOpts {
109    fn default() -> Self {
110        Self::baseline()
111    }
112}
113
114pub fn build_engine(opts: EngineOpts) -> Result<Engine, EngineError> {
115    let mut config = Config::new();
116    config.wasm_gc(true);
117    config.wasm_function_references(true);
118    // Exception-handling proposal: `(error)` lowers to `throw $nomi_error`
119    // and `(handler-case)` / `(unwind-protect)` lower to `try_table`
120    // (Tier 3, ADR-0026). Engine traps (`OutOfFuel` / `EpochInterrupt`)
121    // are not wasm exceptions, so they bypass `try_table` and keep the
122    // per-Session deadline budget non-catchable.
123    config.wasm_exceptions(true);
124    config.epoch_interruption(true);
125    if opts.fuel {
126        config.consume_fuel(true);
127    }
128    match opts.profiler {
129        ProfilerStrategy::None => {}
130        ProfilerStrategy::JitDump => {
131            config.profiler(wasmtime::ProfilingStrategy::JitDump);
132        }
133        ProfilerStrategy::PerfMap => {
134            config.profiler(wasmtime::ProfilingStrategy::PerfMap);
135        }
136    }
137    Engine::new(&config).map_err(|e| EngineError::Config(e.to_string()))
138}
139
140pub fn compile_module(engine: &Engine, bytes: &[u8]) -> Result<Module, EngineError> {
141    Module::new(engine, bytes).map_err(|e| EngineError::Compile(e.to_string()))
142}
143
144pub fn compile_wat(engine: &Engine, source: &str) -> Result<Module, EngineError> {
145    Module::new(engine, source).map_err(|e| EngineError::Compile(e.to_string()))
146}
147
148/// Per-engine bytecode cache keyed by the full module bytes. Cloning a
149/// [`ModuleCache`] yields a handle into the same inner map; meant to be
150/// shared between long-lived hosts (Session, ScriptExecutor) and any per-form
151/// helpers that need the same cached compilation.
152#[derive(Debug, Default, Clone)]
153pub struct ModuleCache {
154    inner: Arc<Mutex<HashMap<Vec<u8>, Module>>>,
155}
156
157impl ModuleCache {
158    #[must_use]
159    pub fn new() -> Self {
160        Self::default()
161    }
162
163    pub fn get_or_compile(&self, engine: &Engine, bytecode: &[u8]) -> Result<Module, EngineError> {
164        if let Some(module) = self.lookup(bytecode)? {
165            return Ok(module);
166        }
167        let module = compile_module(engine, bytecode)?;
168        self.store(bytecode, module.clone())?;
169        Ok(module)
170    }
171
172    fn lookup(&self, bytecode: &[u8]) -> Result<Option<Module>, EngineError> {
173        let guard = self.inner.lock().map_err(|_| EngineError::CachePoisoned)?;
174        Ok(guard.get(bytecode).cloned())
175    }
176
177    fn store(&self, bytecode: &[u8], module: Module) -> Result<(), EngineError> {
178        let mut guard = self.inner.lock().map_err(|_| EngineError::CachePoisoned)?;
179        guard.insert(bytecode.to_vec(), module);
180        Ok(())
181    }
182
183    pub fn is_empty(&self) -> Result<bool, EngineError> {
184        let guard = self.inner.lock().map_err(|_| EngineError::CachePoisoned)?;
185        Ok(guard.is_empty())
186    }
187
188    pub fn len(&self) -> Result<usize, EngineError> {
189        let guard = self.inner.lock().map_err(|_| EngineError::CachePoisoned)?;
190        Ok(guard.len())
191    }
192}
193
194/// Classifies a [`wasmtime::Error`] thrown during execution into a typed
195/// [`EngineError`]. Downcast to [`wasmtime::Trap`] handles the structured
196/// fuel/epoch cases; everything else falls through as `Trap(message)`.
197pub fn classify_runtime_error(err: &wasmtime::Error) -> EngineError {
198    if let Some(trap) = err.downcast_ref::<wasmtime::Trap>() {
199        match *trap {
200            wasmtime::Trap::OutOfFuel => return EngineError::OutOfFuel,
201            wasmtime::Trap::Interrupt => return EngineError::EpochInterrupt,
202            _ => {}
203        }
204    }
205    // Walk the error chain so host-fn `wasmtime::Error::msg("...")` causes
206    // (e.g. "get-commodity: invalid uuid '...'") surface alongside the
207    // wasmtime wrapper's "error while executing at wasm backtrace" header.
208    // `err.to_string()` alone only renders the outermost wrapper, which
209    // hides the diagnostic the host fn actually emitted.
210    let mut combined = err.to_string();
211    for cause in err.chain().skip(1) {
212        combined.push_str(": ");
213        combined.push_str(&cause.to_string());
214    }
215    // Script-raised errors must classify *before* the unreachable-trap
216    // branch: `(error 'code "msg")` lowers to `__nomi_raise` returning
217    // `Err(wasmtime::Error::msg("__nomi_raise:CODE:MSG"))`. Returning
218    // `Err` from a host fn never trips `unreachable`, so ADR-0014's
219    // single-unreachable invariant is preserved — but the chain walk
220    // above stitches the host-fn message into `combined`, and the
221    // marker prefix lets us recover the symbol/message without parsing
222    // wasmtime's wrapper text.
223    if let Some(raised) = parse_nomi_raise_marker(err) {
224        return raised;
225    }
226    if combined.contains("convert-commodity: no Price row")
227        || combined.contains("convert-commodity: inverse price has zero numerator")
228    {
229        return EngineError::NoConversion(combined);
230    }
231    EngineError::Trap(combined)
232}
233
234/// Walks the error chain for a `__nomi_raise:CODE:MSG` marker emitted by
235/// the `__nomi_raise` host fn. Returns the structured `ScriptRaised`
236/// variant when found, otherwise `None`. Searches the chain rather than
237/// the combined string so script-raised codes survive intact even when
238/// MSG itself contains literal `:` characters.
239fn parse_nomi_raise_marker(err: &wasmtime::Error) -> Option<EngineError> {
240    err.chain()
241        .map(|cause| cause.to_string())
242        .find_map(|cause_str| split_marker(&cause_str))
243}
244
245fn split_marker(text: &str) -> Option<EngineError> {
246    let rest = text.strip_prefix(NOMI_RAISE_MARKER)?;
247    let (code, message) = rest.split_once(':')?;
248    Some(EngineError::ScriptRaised {
249        code: code.to_string(),
250        message: message.to_string(),
251    })
252}
253
254/// Maps an `EngineError` to the `(code, message)` pair scripts and
255/// batch consumers see when a script fails — code is a kebab-case
256/// symbol (matching the wire envelope's `:code` slot for catch-each
257/// cells and `server::script` per-tx reports), message is the
258/// engine's own diagnostic string.
259///
260/// Engine-bound deadlines (`OutOfFuel`, `EpochInterrupt`) also have a
261/// mapping here so callers that *do* want to surface them to scripts
262/// (batch runners that don't care about catch-each's "engine deadlines
263/// aren't catchable" rule) can. catch-each filters the deadlines out
264/// before reaching this mapper.
265#[must_use]
266pub fn err_code_and_message(err: &EngineError) -> (String, String) {
267    match err {
268        EngineError::ScriptRaised { code, message } => (code.clone(), message.clone()),
269        EngineError::NoConversion(msg) => ("no-conversion".to_string(), msg.clone()),
270        EngineError::Trap(msg) => ("runtime".to_string(), msg.clone()),
271        EngineError::Compile(msg) => ("compile".to_string(), msg.clone()),
272        EngineError::Instantiate(msg) => ("runtime".to_string(), msg.clone()),
273        EngineError::Fuel(msg) => ("runtime".to_string(), msg.clone()),
274        EngineError::MissingExport(msg) => ("runtime".to_string(), msg.clone()),
275        EngineError::Config(msg) => ("runtime".to_string(), msg.clone()),
276        EngineError::CachePoisoned => (
277            "runtime".to_string(),
278            "module cache lock poisoned".to_string(),
279        ),
280        EngineError::OutOfFuel => ("runtime".to_string(), "fuel exhausted".to_string()),
281        EngineError::EpochInterrupt => ("runtime".to_string(), "epoch deadline".to_string()),
282    }
283}
284
285/// Allocates an ATOMIC `$commodity` value by re-entering the guest's exported
286/// `commodity_new` with the four i64 components (numer, denom, commodity_hi,
287/// commodity_lo). Since ADR-0028 E0 the `$commodity` struct carries a 5th
288/// `(ref null $unit_term)` field; the host must NOT construct that ref-bearing
289/// struct itself, so it delegates to the guest helper (which sets the term to
290/// null = atomic) — the same re-entry pattern as `alloc_pair_chain` / the
291/// entity allocators. Async because it calls back into the wasm instance.
292pub async fn alloc_commodity_ref<T>(
293    caller: &mut Caller<'_, T>,
294    numer: i64,
295    denom: i64,
296    commodity_id: Uuid,
297) -> wasmtime::Result<Rooted<StructRef>>
298where
299    T: Send,
300{
301    let commodity_new = caller
302        .get_export("commodity_new")
303        .and_then(|e| e.into_func())
304        .ok_or_else(|| {
305            wasmtime::Error::msg(
306                "module missing 'commodity_new' export — host commodity allocation \
307                 requires the nomiscript compiler skeleton's exported commodity_new",
308            )
309        })?;
310    let (hi, lo) = commodity_id.as_u64_pair();
311    let mut results = [Val::AnyRef(None)];
312    commodity_new
313        .call_async(
314            caller.as_context_mut(),
315            &[
316                Val::I64(numer),
317                Val::I64(denom),
318                Val::I64(hi as i64),
319                Val::I64(lo as i64),
320            ],
321            &mut results,
322        )
323        .await?;
324    match &results[0] {
325        Val::AnyRef(Some(any)) => any.unwrap_struct(caller.as_context_mut()),
326        Val::AnyRef(None) => Err(wasmtime::Error::msg("commodity_new returned null")),
327        _ => Err(wasmtime::Error::msg(
328            "commodity_new returned non-anyref Val variant",
329        )),
330    }
331}
332
333/// Allocates a `$i8_array` wasm array holding `bytes` and returns a rooted
334/// reference. Single allocation; callers can format UUID/name payloads into
335/// a reused `Vec<u8>` and ship the bytes without an intermediate `String`.
336/// Engine canonicalizes the i8 array type so the host-side allocation
337/// matches the guest's `(array i8)` declaration in
338/// `CompileContext::new_skeleton`.
339pub fn alloc_string_ref<T>(
340    caller: &mut Caller<'_, T>,
341    bytes: &[u8],
342) -> wasmtime::Result<Rooted<wasmtime::ArrayRef>> {
343    let engine = caller.engine().clone();
344    // Mutability must match the compiler's `register_type("i8_array")` field
345    // declaration (mutable: true) — engine canonicalization compares the
346    // mutability bit, so a `Const` array type would fail the guest's
347    // `ref.cast (ref $i8_array)` even though the storage type matches.
348    let ty = wasmtime::ArrayType::new(&engine, FieldType::new(Mutability::Var, StorageType::I8));
349    let pre = wasmtime::ArrayRefPre::new(caller.as_context_mut(), ty);
350    let vals: Vec<Val> = bytes.iter().map(|b| Val::I32(i32::from(*b))).collect();
351    wasmtime::ArrayRef::new_fixed(caller.as_context_mut(), &pre, &vals)
352}
353
354/// Allocates a `$ratio` wasm struct (2 i64 fields: numer, denom). Mirrors
355/// `alloc_commodity_ref` for the Ratio numeric stratum — used when a host
356/// fn returns a typed Ratio without going through the synthesized
357/// `ratio_new` wrap.
358pub fn alloc_ratio_ref<T>(
359    caller: &mut Caller<'_, T>,
360    numer: i64,
361    denom: i64,
362) -> wasmtime::Result<Rooted<StructRef>> {
363    let engine = caller.engine().clone();
364    let ty = StructType::new(
365        &engine,
366        std::iter::repeat_n(
367            FieldType::new(Mutability::Const, StorageType::ValType(ValType::I64)),
368            2,
369        ),
370    )?;
371    let pre = StructRefPre::new(caller.as_context_mut(), ty);
372    StructRef::new(
373        caller.as_context_mut(),
374        &pre,
375        &[Val::I64(numer), Val::I64(denom)],
376    )
377}
378
379/// Allocates an entity wasm struct (`$account`, `$commodity_entity`, etc) by
380/// re-entering the module's exported `alloc_<kind>` function. The host can't
381/// freshly construct an entity `StructType` via `StructType::new` — fields
382/// like `(ref null $i8_array)` reference concrete type indices that engine
383/// canonicalization compares by identity, so any abstract `anyref`-typed
384/// fresh declaration produces a structurally distinct (and uncastable) type.
385/// Re-entry through the guest's own allocator (registered in
386/// `CompileContext::register_entity_allocators`) sidesteps the issue: each
387/// call returns a struct ref of the exact `$<kind>` type the subsequent
388/// `ref.cast (ref $<kind>)` in the consuming form accepts.
389///
390/// Args are passed in declaration order matching the entity's struct
391/// field layout (see `CompileContext::new_skeleton`).
392pub async fn alloc_entity_via_export<T>(
393    caller: &mut Caller<'_, T>,
394    export_name: &str,
395    args: &[Val],
396) -> wasmtime::Result<Rooted<StructRef>>
397where
398    T: Send,
399{
400    let alloc = caller
401        .get_export(export_name)
402        .and_then(|e| e.into_func())
403        .ok_or_else(|| {
404            wasmtime::Error::msg(format!(
405                "module missing '{export_name}' export — host entity allocation requires \
406                 the nomiscript compiler skeleton's exported alloc_<kind> function"
407            ))
408        })?;
409    let mut results = [Val::AnyRef(None)];
410    alloc
411        .call_async(caller.as_context_mut(), args, &mut results)
412        .await?;
413    let new_entity_any = match &results[0] {
414        Val::AnyRef(any) => *any,
415        _ => {
416            return Err(wasmtime::Error::msg(format!(
417                "{export_name} returned non-anyref Val variant"
418            )));
419        }
420    };
421    new_entity_any
422        .ok_or_else(|| {
423            wasmtime::Error::msg(format!(
424                "{export_name} returned null when allocating entity"
425            ))
426        })?
427        .unwrap_struct(caller.as_context_mut())
428}
429
430/// Reads a `$i8_array` arg ref into a Rust `String`. `None` is returned for
431/// null refs (the wasm-level `(ref null $i8_array)` param's null state). The
432/// underlying byte storage is i8 (mutable per `register_type("i8_array")`),
433/// so each element is read via `array.get_u` semantics and assembled into a
434/// `Vec<u8>` then UTF-8 validated. Non-UTF-8 bytes surface as a structured
435/// trap rather than a silent replacement.
436pub fn read_string_arg<T>(
437    caller: &mut Caller<'_, T>,
438    arg: Option<Rooted<wasmtime::ArrayRef>>,
439) -> wasmtime::Result<Option<String>> {
440    let Some(arr) = arg else {
441        return Ok(None);
442    };
443    let len = arr.len(caller.as_context_mut())?;
444    let mut bytes = Vec::with_capacity(len as usize);
445    for i in 0..len {
446        let val = arr.get(caller.as_context_mut(), i)?;
447        let byte_i32 = val
448            .i32()
449            .ok_or_else(|| wasmtime::Error::msg("string arg element is not i32"))?;
450        bytes.push(byte_i32 as u8);
451    }
452    String::from_utf8(bytes)
453        .map(Some)
454        .map_err(|err| wasmtime::Error::msg(format!("string arg is not valid UTF-8: {err}")))
455}
456
457/// Reads a `$commodity` arg ref into its (numer, denom, commodity_id)
458/// components. Mirrors `read_string_arg` for the Commodity numeric stratum:
459/// fields 0-1 are numer/denom, fields 2-3 are the UUID halves. `None`
460/// returns for a null ref; bad shape surfaces as a structured trap.
461pub fn read_commodity_arg<T>(
462    caller: &mut Caller<'_, T>,
463    arg: Option<Rooted<StructRef>>,
464) -> wasmtime::Result<Option<(i64, i64, Uuid)>> {
465    let Some(s) = arg else {
466        return Ok(None);
467    };
468    let read_i64 = |c: &mut Caller<'_, T>, idx: usize| -> wasmtime::Result<i64> {
469        let v = s.field(c.as_context_mut(), idx)?;
470        v.i64()
471            .ok_or_else(|| wasmtime::Error::msg(format!("commodity field {idx} is not i64")))
472    };
473    let numer = read_i64(caller, 0)?;
474    let denom = read_i64(caller, 1)?;
475    let hi = read_i64(caller, 2)?;
476    let lo = read_i64(caller, 3)?;
477    let raw = ((hi as u64 as u128) << 64) | (lo as u64 as u128);
478    Ok(Some((numer, denom, Uuid::from_u128(raw))))
479}
480
481/// Reads a `$ratio` arg ref into its `(numer, denom)` components. `None`
482/// returns for a null ref; a zero denominator is rejected as a structured trap
483/// so callers never divide by zero. Mirrors `read_commodity_arg` for the
484/// dimensionless Scalar stratum (a `draft-split` amount).
485pub fn read_ratio_arg<T>(
486    caller: &mut Caller<'_, T>,
487    arg: Option<Rooted<StructRef>>,
488) -> wasmtime::Result<Option<(i64, i64)>> {
489    let Some(s) = arg else {
490        return Ok(None);
491    };
492    let numer = s
493        .field(caller.as_context_mut(), 0)?
494        .i64()
495        .ok_or_else(|| wasmtime::Error::msg("ratio field 0 (numer) is not i64"))?;
496    let denom = s
497        .field(caller.as_context_mut(), 1)?
498        .i64()
499        .ok_or_else(|| wasmtime::Error::msg("ratio field 1 (denom) is not i64"))?;
500    if denom == 0 {
501        return Err(wasmtime::Error::msg("ratio has zero denominator"));
502    }
503    Ok(Some((numer, denom)))
504}
505
506/// Reads a named String field from an entity struct arg, resolving the field's
507/// slot index from [`nomiscript::entity_layout`] (the single source of struct
508/// slot order). `None` returns for a null ref. Errors if the kind has no
509/// layout, the named field is absent or non-String, or the slot is not an
510/// `$i8_array`. This is how the draft natives read e.g. an account's `id`
511/// (slot 0) from a `(get-account …)` entity ref passed as an argument.
512pub fn read_entity_string_field<T>(
513    caller: &mut Caller<'_, T>,
514    arg: Option<Rooted<StructRef>>,
515    kind: nomiscript::EntityKind,
516    field_name: &str,
517) -> wasmtime::Result<Option<String>> {
518    let Some(s) = arg else {
519        return Ok(None);
520    };
521    read_entity_string_field_ctx(caller.as_context_mut(), s, kind, field_name).map(Some)
522}
523
524/// Context-based core of [`read_entity_string_field`], split out so it is
525/// unit-testable without a `Caller` (tests hold a bare `Store`). Resolves the
526/// field's slot from the entity layout and reads it as an `$i8_array` string.
527pub fn read_entity_string_field_ctx(
528    mut store: impl AsContextMut,
529    entity: Rooted<StructRef>,
530    kind: nomiscript::EntityKind,
531    field_name: &str,
532) -> wasmtime::Result<String> {
533    let layout = nomiscript::entity_layout(kind)
534        .ok_or_else(|| wasmtime::Error::msg(format!("no entity layout for {kind:?}")))?;
535    let idx = layout
536        .fields
537        .iter()
538        .position(|f| f.name == field_name && f.kind == nomiscript::EntityFieldKind::String)
539        .ok_or_else(|| {
540            wasmtime::Error::msg(format!(
541                "entity {kind:?} has no String field named '{field_name}'"
542            ))
543        })?;
544    let arr = match entity.field(store.as_context_mut(), idx)? {
545        Val::AnyRef(Some(any)) => any.unwrap_array(store.as_context_mut())?,
546        _ => {
547            return Err(wasmtime::Error::msg(format!(
548                "entity {kind:?} field '{field_name}' (slot {idx}) is not an i8_array"
549            )));
550        }
551    };
552    let len = arr.len(store.as_context_mut())?;
553    let mut bytes = Vec::with_capacity(len as usize);
554    for i in 0..len {
555        let byte = arr
556            .get(store.as_context_mut(), i)?
557            .i32()
558            .ok_or_else(|| wasmtime::Error::msg("entity string element is not i32"))?;
559        bytes.push(byte as u8);
560    }
561    String::from_utf8(bytes)
562        .map_err(|err| wasmtime::Error::msg(format!("entity string field not utf-8: {err}")))
563}
564
565/// Folds an iterator of GC-ref elements into a `$pair` chain by re-entering
566/// the wasm module via its exported `pair_new` function. Returns the chain
567/// head, or `None` if the iterator is empty.
568///
569/// `$pair` is the self-recursive cell type (`{anyref car, ref null $pair
570/// cdr}`) declared by `CompileContext::new_skeleton`. The host can't freshly
571/// construct that StructType via `StructType::new` — the cdr field references
572/// the type itself, which `StructType::new` doesn't model. Instead this
573/// helper reaches into the module's own type system: `pair_new` is already
574/// emitted by the compiler skeleton as `register_function("pair_new", ...)`
575/// which adds it to the export table, so the host fn body can pull it from
576/// `Caller::get_export` and invoke it per element. Each call produces a
577/// `Rooted<StructRef>` of the exact `$pair` type that subsequent `ref.cast
578/// (ref null $pair)` operations in the guest accept.
579///
580/// Items are folded right-to-left so the first element of the iterator
581/// ends up at the chain head — `[a, b, c]` → `(a . (b . (c . nil)))`.
582pub async fn alloc_pair_chain<T>(
583    caller: &mut Caller<'_, T>,
584    items: impl IntoIterator<Item = Rooted<AnyRef>>,
585) -> wasmtime::Result<Option<Rooted<StructRef>>>
586where
587    T: Send,
588{
589    let pair_new = caller
590        .get_export("pair_new")
591        .and_then(|e| e.into_func())
592        .ok_or_else(|| {
593            wasmtime::Error::msg(
594                "module missing 'pair_new' export — host pair allocation requires \
595                 the nomiscript compiler skeleton's exported pair_new",
596            )
597        })?;
598
599    let items: Vec<Rooted<AnyRef>> = items.into_iter().collect();
600    let mut head: Option<Rooted<StructRef>> = None;
601    for item in items.into_iter().rev() {
602        let cdr_any = head.map(|p| p.to_anyref());
603        let mut results = [Val::AnyRef(None)];
604        pair_new
605            .call_async(
606                caller.as_context_mut(),
607                &[Val::AnyRef(Some(item)), Val::AnyRef(cdr_any)],
608                &mut results,
609            )
610            .await?;
611        let new_pair_any = match &results[0] {
612            Val::AnyRef(any) => *any,
613            _ => {
614                return Err(wasmtime::Error::msg(
615                    "pair_new returned non-anyref Val variant",
616                ));
617            }
618        };
619        head = Some(
620            new_pair_any
621                .ok_or_else(|| {
622                    wasmtime::Error::msg("pair_new returned null when chaining elements")
623                })?
624                .unwrap_struct(caller.as_context_mut())?,
625        );
626    }
627    Ok(head)
628}
629
630/// Instantiates `module` against an empty linker and calls a zero-arg export
631/// returning a single `i64`. Constraints (fuel cap, epoch deadline) come from
632/// the caller-supplied `Store`.
633pub fn call_i64_export<T>(
634    engine: &Engine,
635    store: &mut Store<T>,
636    module: &Module,
637    export: &str,
638) -> Result<i64, EngineError> {
639    let linker = Linker::<T>::new(engine);
640    let instance = linker
641        .instantiate(&mut *store, module)
642        .map_err(|e| classify_runtime_error(&e))?;
643    let func = instance
644        .get_typed_func::<(), i64>(&mut *store, export)
645        .map_err(|_| EngineError::MissingExport(export.to_string()))?;
646    func.call(&mut *store, ())
647        .map_err(|e| classify_runtime_error(&e))
648}
649
650/// Final value captured by an eval-mode module via the `nomi_capture_*` host
651/// fns. Mirrors the subset of [`nomiscript::WasmType`] variants the compiler
652/// emits as terminal stack types, plus a `Bytes` variant for native fns that
653/// marshal compound data (server-command results via `scripting-format`,
654/// chart SVGs, exported files, etc.). Cons/Vector/Closure/Struct still wait
655/// for the GC migration.
656#[derive(Debug, Clone, PartialEq)]
657pub enum EvalValue {
658    Nil,
659    Bool(bool),
660    I32(i32),
661    Ratio {
662        numer: i64,
663        denom: i64,
664    },
665    /// Commodity-bearing amount: rational + originating commodity uuid.
666    /// Distinct from `Ratio` so cross-strata arithmetic is rejected by
667    /// the compiler before any wire round-trip. Wire form via
668    /// `format_value`: `(:commodity <ratio> :id "<uuid>")`.
669    Commodity {
670        numer: i64,
671        denom: i64,
672        commodity_hi: i64,
673        commodity_lo: i64,
674    },
675    String(String),
676    Bytes(Vec<u8>),
677}
678
679impl From<EvalValue> for nomiscript::Value {
680    fn from(value: EvalValue) -> Self {
681        match value {
682            EvalValue::Nil => nomiscript::Value::Nil,
683            EvalValue::Bool(b) => nomiscript::Value::Bool(b),
684            EvalValue::I32(n) => {
685                nomiscript::Value::Number(nomiscript::Fraction::from_integer(i64::from(n)))
686            }
687            EvalValue::Ratio { numer, denom } => {
688                nomiscript::Value::Number(nomiscript::Fraction::new(numer, denom))
689            }
690            EvalValue::Commodity {
691                numer,
692                denom,
693                commodity_hi,
694                commodity_lo,
695            } => {
696                // Reassemble the 16-byte uuid from the wasm-side (hi, lo)
697                // i64 pair. Both halves get cast to u64 first so negative
698                // i64 patterns don't sign-extend into bogus high bits.
699                let raw = ((commodity_hi as u64 as u128) << 64) | (commodity_lo as u64 as u128);
700                nomiscript::Value::Commodity {
701                    amount: nomiscript::Fraction::new(numer, denom),
702                    commodity_id: uuid::Uuid::from_u128(raw),
703                }
704            }
705            EvalValue::String(s) => nomiscript::Value::String(s),
706            EvalValue::Bytes(b) => nomiscript::Value::Bytes(b),
707        }
708    }
709}
710
711/// Decodes nomi-eval's anyref return value into an [`EvalValue`] using
712/// the compile-time-known result type. `None` for the result_ty means
713/// the form was empty / definition-only and the host should see
714/// [`EvalValue::Nil`]. Numeric types (`I32`, `Ratio`, `Commodity`) and
715/// `StringRef` decode directly. `PairRef` walks the chain, decoding
716/// each car per its declared element type. `EntityRef` returns a
717/// placeholder until a downstream consumer needs the structured
718/// shape host-side. Takes any `AsContextMut` so it works with both
719/// `&mut Store<T>` (sync test paths) and `&mut Caller<'_, T>` (async
720/// host fn paths).
721pub fn decode_eval_result(
722    mut store: impl AsContextMut,
723    value: Option<Rooted<AnyRef>>,
724    result_ty: Option<nomiscript::WasmType>,
725) -> wasmtime::Result<EvalValue> {
726    let Some(ty) = result_ty else {
727        return Ok(EvalValue::Nil);
728    };
729    // Reference-typed results can legitimately be null: empty
730    // `pair<…>` from `list-accounts`, `Option<Rooted<…>>` returns
731    // surfacing not-found / missing-string cases. Surface those as
732    // `Nil` so consumers see an empty-shaped value rather than a
733    // trap. Primitive (I32) returns can't be null and stay strict.
734    let Some(any) = value else {
735        return match ty {
736            nomiscript::WasmType::I32 => Err(wasmtime::Error::msg(
737                "nomi-eval returned null for declared result type i32",
738            )),
739            nomiscript::WasmType::PairRef(_) => Ok(EvalValue::String("()".into())),
740            _ => Ok(EvalValue::Nil),
741        };
742    };
743    decode_anyref(&mut store, any, ty)
744}
745
746fn decode_anyref(
747    mut store: impl AsContextMut,
748    any: Rooted<AnyRef>,
749    ty: nomiscript::WasmType,
750) -> wasmtime::Result<EvalValue> {
751    use nomiscript::WasmType;
752    match ty {
753        WasmType::I32 => {
754            let i31 = any
755                .unwrap_i31(&mut store)
756                .map_err(|err| wasmtime::Error::msg(format!("expected i31, got {err}")))?;
757            Ok(EvalValue::I32(i31.get_i32()))
758        }
759        WasmType::Bool => {
760            // A boolean result is i31-boxed like an i32, but decodes to the
761            // falsy-nil / truthy-bool pair nomiscript uses (matches the
762            // const-fold path): 0 → Nil, nonzero → Bool(true).
763            let i31 = any
764                .unwrap_i31(&mut store)
765                .map_err(|err| wasmtime::Error::msg(format!("expected i31, got {err}")))?;
766            if i31.get_i32() == 0 {
767                Ok(EvalValue::Nil)
768            } else {
769                Ok(EvalValue::Bool(true))
770            }
771        }
772        WasmType::Ratio => {
773            let s = any.unwrap_struct(&mut store)?;
774            let numer = s
775                .field(&mut store, 0)?
776                .i64()
777                .ok_or_else(|| wasmtime::Error::msg("ratio field 0 (numer) is not i64"))?;
778            let denom = s
779                .field(&mut store, 1)?
780                .i64()
781                .ok_or_else(|| wasmtime::Error::msg("ratio field 1 (denom) is not i64"))?;
782            Ok(EvalValue::Ratio { numer, denom })
783        }
784        WasmType::Commodity => {
785            let s = any.unwrap_struct(&mut store)?;
786            let numer = s.field(&mut store, 0)?.i64().unwrap_or(0);
787            let denom = s.field(&mut store, 1)?.i64().unwrap_or(1);
788            // Field 4 is the unit term (ADR-0028). Null ⇒ ATOMIC single-currency
789            // money (id in fields 2-3). An empty term ⇒ DIMENSIONLESS (money ÷
790            // money, same currency) and decodes as a plain Number. A non-empty
791            // (compound) term — e.g. money×money — has no host wire form yet.
792            match s.field(&mut store, 4)? {
793                Val::AnyRef(None) => {
794                    let hi = s.field(&mut store, 2)?.i64().unwrap_or(0);
795                    let lo = s.field(&mut store, 3)?.i64().unwrap_or(0);
796                    Ok(EvalValue::Commodity {
797                        numer,
798                        denom,
799                        commodity_hi: hi,
800                        commodity_lo: lo,
801                    })
802                }
803                Val::AnyRef(Some(term)) => {
804                    let arr = term.unwrap_array(&mut store)?;
805                    if arr.len(&mut store)? == 0 {
806                        Ok(EvalValue::Ratio { numer, denom })
807                    } else {
808                        Err(wasmtime::Error::msg(
809                            "compound commodity (e.g. money × money) has no host \
810                             representation yet",
811                        ))
812                    }
813                }
814                _ => Err(wasmtime::Error::msg(
815                    "commodity field 4 (unit term) is not a ref",
816                )),
817            }
818        }
819        WasmType::StringRef => {
820            let arr = any.unwrap_array(&mut store)?;
821            let len = arr.len(&mut store)?;
822            let mut bytes = Vec::with_capacity(len as usize);
823            for i in 0..len {
824                let v = arr.get(&mut store, i)?;
825                let byte = v
826                    .i32()
827                    .ok_or_else(|| wasmtime::Error::msg("string element is not i32"))?;
828                bytes.push(byte as u8);
829            }
830            let s = String::from_utf8(bytes)
831                .map_err(|err| wasmtime::Error::msg(format!("not valid utf-8: {err}")))?;
832            Ok(EvalValue::String(s))
833        }
834        WasmType::PairRef(elem) => {
835            let head = render_pair_as_string(&mut store, any, elem)?;
836            Ok(EvalValue::String(head))
837        }
838        WasmType::EntityRef(kind) => {
839            let entity = any.unwrap_struct(&mut store)?;
840            Ok(EvalValue::String(render_entity(&mut store, entity, kind)?))
841        }
842        WasmType::Closure(_) => {
843            let _ = any;
844            Ok(EvalValue::String("<closure>".into()))
845        }
846        WasmType::AnyRef => {
847            // Heterogeneous payload (catch-each result cells, etc.).
848            // The host renderer cannot statically pick a decoder, so it
849            // surfaces a placeholder; the script-side accessor natives
850            // (`ok?` / `err-code` / etc.) are responsible for inspecting
851            // the contents.
852            let _ = any;
853            Ok(EvalValue::String("<anyref>".into()))
854        }
855    }
856}
857
858/// Walks a `$pair` chain and renders it as a Lisp-style list-of-cars
859/// textual form `( <car> <car> ... )`. Element decoding dispatches on
860/// the compile-time PairElement so each car gets its proper formatter.
861fn render_pair_as_string(
862    mut store: impl AsContextMut,
863    head_any: Rooted<AnyRef>,
864    elem: nomiscript::PairElement,
865) -> wasmtime::Result<String> {
866    let mut out = String::from("(");
867    let mut cur: Option<Rooted<StructRef>> = Some(head_any.unwrap_struct(&mut store)?);
868    let mut first = true;
869    while let Some(node) = cur {
870        if !first {
871            out.push(' ');
872        }
873        first = false;
874        let car_val = node.field(&mut store, 0)?;
875        let car_any = match car_val {
876            Val::AnyRef(Some(a)) => a,
877            Val::AnyRef(None) => {
878                out.push_str("nil");
879                let cdr_val = node.field(&mut store, 1)?;
880                cur = match cdr_val {
881                    Val::AnyRef(Some(a)) => Some(a.unwrap_struct(&mut store)?),
882                    _ => None,
883                };
884                continue;
885            }
886            _ => {
887                return Err(wasmtime::Error::msg("pair car is not anyref"));
888            }
889        };
890        let car_str = render_car(&mut store, car_any, elem)?;
891        out.push_str(&car_str);
892        let cdr_val = node.field(&mut store, 1)?;
893        cur = match cdr_val {
894            Val::AnyRef(Some(a)) => Some(a.unwrap_struct(&mut store)?),
895            _ => None,
896        };
897    }
898    out.push(')');
899    Ok(out)
900}
901
902fn render_car(
903    mut store: impl AsContextMut,
904    car_any: Rooted<AnyRef>,
905    elem: nomiscript::PairElement,
906) -> wasmtime::Result<String> {
907    use nomiscript::PairElement;
908    match elem {
909        PairElement::I32 => {
910            let i31 = car_any.unwrap_i31(&mut store)?;
911            Ok(i31.get_i32().to_string())
912        }
913        PairElement::Bool => {
914            // Shares the i31 car with I32, but renders as a truth value: 0 →
915            // `nil`, nonzero → `t` (matching the bool/nil wire convention).
916            let i31 = car_any.unwrap_i31(&mut store)?;
917            Ok(if i31.get_i32() == 0 { "nil" } else { "t" }.to_string())
918        }
919        PairElement::Ratio => {
920            let s = car_any.unwrap_struct(&mut store)?;
921            let n = s.field(&mut store, 0)?.i64().unwrap_or(0);
922            let d = s.field(&mut store, 1)?.i64().unwrap_or(1);
923            if d == 1 {
924                Ok(n.to_string())
925            } else {
926                Ok(format!("{n}/{d}"))
927            }
928        }
929        PairElement::Commodity => {
930            let s = car_any.unwrap_struct(&mut store)?;
931            let n = s.field(&mut store, 0)?.i64().unwrap_or(0);
932            let d = s.field(&mut store, 1)?.i64().unwrap_or(1);
933            // Field 4 (unit term, ADR-0028) decides the wire form — SAME rules
934            // as the top-level `decode_anyref` Commodity arm, so a compound
935            // money riding a `$pair` cell can't slip through as id-zero atomic
936            // money: null ⇒ atomic (id in fields 2-3), empty ⇒ dimensionless
937            // Number, non-empty (compound) ⇒ error (no host wire form yet).
938            match s.field(&mut store, 4)? {
939                Val::AnyRef(None) => {
940                    let hi = s.field(&mut store, 2)?.i64().unwrap_or(0);
941                    let lo = s.field(&mut store, 3)?.i64().unwrap_or(0);
942                    let raw = ((hi as u64 as u128) << 64) | (lo as u64 as u128);
943                    let id = Uuid::from_u128(raw);
944                    if d == 1 {
945                        Ok(format!("(:commodity {n} :id \"{id}\")"))
946                    } else {
947                        Ok(format!("(:commodity {n}/{d} :id \"{id}\")"))
948                    }
949                }
950                Val::AnyRef(Some(term)) => {
951                    let arr = term.unwrap_array(&mut store)?;
952                    if arr.len(&mut store)? == 0 {
953                        Ok(if d == 1 {
954                            n.to_string()
955                        } else {
956                            format!("{n}/{d}")
957                        })
958                    } else {
959                        Err(wasmtime::Error::msg(
960                            "compound commodity (e.g. money × money) has no host \
961                             representation yet",
962                        ))
963                    }
964                }
965                _ => Err(wasmtime::Error::msg(
966                    "commodity field 4 (unit term) is not a ref",
967                )),
968            }
969        }
970        PairElement::StringRef => {
971            let arr = car_any.unwrap_array(&mut store)?;
972            let len = arr.len(&mut store)?;
973            let mut bytes = Vec::with_capacity(len as usize);
974            for i in 0..len {
975                let v = arr.get(&mut store, i)?;
976                bytes.push(v.i32().unwrap_or(0) as u8);
977            }
978            let s = String::from_utf8(bytes).unwrap_or_else(|_| "<invalid-utf8>".into());
979            Ok(nomiscript::format_value(&nomiscript::Value::String(s)))
980        }
981        PairElement::Entity(kind) => {
982            let entity = car_any.unwrap_struct(&mut store)?;
983            render_entity(&mut store, entity, kind)
984        }
985        PairElement::AnyRef => Ok("<anyref>".into()),
986    }
987}
988
989/// Renders a typed entity struct as a readable plist —
990/// `(:commodity :id "…" :symbol "USD" :name "US Dollar")` — by reading each
991/// field at its `struct.get` slot per the single-source-of-truth
992/// [`nomiscript::entity_layout`] (generated from `entity_registry.org`). Field
993/// slot order in the layout matches the wasm struct exactly. A `Pair` field
994/// renders as `()`-elided; the host has no element-type context to walk it.
995fn render_entity(
996    mut store: impl AsContextMut,
997    entity: Rooted<StructRef>,
998    kind: nomiscript::EntityKind,
999) -> wasmtime::Result<String> {
1000    use nomiscript::EntityFieldKind;
1001
1002    let Some(layout) = nomiscript::entity_layout(kind) else {
1003        // No field layout (e.g. Condition isn't a server entity).
1004        return Ok(format!("(:{kind:?})"));
1005    };
1006    let mut out = format!("(:{}", layout.label);
1007    for (slot, field) in layout.fields.iter().enumerate() {
1008        let rendered = match field.kind {
1009            EntityFieldKind::String => read_string_slot(&mut store, entity, slot)?,
1010            EntityFieldKind::Ratio => read_ratio_slot(&mut store, entity, slot)?,
1011            EntityFieldKind::I32 => entity
1012                .field(&mut store, slot)?
1013                .i32()
1014                .unwrap_or(0)
1015                .to_string(),
1016            EntityFieldKind::Pair => "(...)".to_string(),
1017        };
1018        out.push_str(&format!(" :{} {rendered}", field.name));
1019    }
1020    out.push(')');
1021    Ok(out)
1022}
1023
1024/// Reads a `(ref null $i8_array)` string field at `slot`, rendered as a quoted
1025/// literal. A null/empty slot renders as `""`.
1026fn read_string_slot(
1027    mut store: impl AsContextMut,
1028    entity: Rooted<StructRef>,
1029    slot: usize,
1030) -> wasmtime::Result<String> {
1031    match entity.field(&mut store, slot)? {
1032        Val::AnyRef(Some(a)) => {
1033            let arr = a.unwrap_array(&mut store)?;
1034            let len = arr.len(&mut store)?;
1035            let mut bytes = Vec::with_capacity(len as usize);
1036            for i in 0..len {
1037                bytes.push(arr.get(&mut store, i)?.i32().unwrap_or(0) as u8);
1038            }
1039            let s = String::from_utf8(bytes).unwrap_or_else(|_| "<invalid-utf8>".into());
1040            Ok(nomiscript::format_value(&nomiscript::Value::String(s)))
1041        }
1042        _ => Ok("\"\"".to_string()),
1043    }
1044}
1045
1046/// Reads a `(ref null $ratio)` field at `slot` (i64 numer/denom in slots 0/1 of
1047/// the ratio struct), rendered as `n` or `n/d`. A null slot renders as `0`.
1048fn read_ratio_slot(
1049    mut store: impl AsContextMut,
1050    entity: Rooted<StructRef>,
1051    slot: usize,
1052) -> wasmtime::Result<String> {
1053    match entity.field(&mut store, slot)? {
1054        Val::AnyRef(Some(a)) => {
1055            let s = a.unwrap_struct(&mut store)?;
1056            let n = s.field(&mut store, 0)?.i64().unwrap_or(0);
1057            let d = s.field(&mut store, 1)?.i64().unwrap_or(1);
1058            Ok(if d == 1 {
1059                n.to_string()
1060            } else {
1061                format!("{n}/{d}")
1062            })
1063        }
1064        _ => Ok("0".to_string()),
1065    }
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070    use super::*;
1071
1072    #[test]
1073    fn err_code_uses_script_raised_symbol_verbatim() {
1074        let (code, msg) = err_code_and_message(&EngineError::ScriptRaised {
1075            code: "no-such-account".to_string(),
1076            message: "id=42".to_string(),
1077        });
1078        assert_eq!(code, "no-such-account");
1079        assert_eq!(msg, "id=42");
1080    }
1081
1082    #[test]
1083    fn err_code_maps_commodity_mismatch_script_raise_to_symbol() {
1084        // Commodity mismatch now `throw`s `$nomi_error` in-guest (ADR-0026);
1085        // uncaught, the boundary wrapper bridges it to `__nomi_raise` and the
1086        // classifier yields `ScriptRaised`. The code is the reader-folded
1087        // (upper-cased) symbol `COMMODITY-MISMATCH`, like any script raise —
1088        // `err_code_and_message` passes a `ScriptRaised` code through verbatim.
1089        let (code, msg) = err_code_and_message(&EngineError::ScriptRaised {
1090            code: "COMMODITY-MISMATCH".to_string(),
1091            message: "USD vs EUR".to_string(),
1092        });
1093        assert_eq!(code, "COMMODITY-MISMATCH");
1094        assert_eq!(msg, "USD vs EUR");
1095    }
1096
1097    #[test]
1098    fn err_code_maps_no_conversion_to_kebab_symbol() {
1099        let (code, msg) =
1100            err_code_and_message(&EngineError::NoConversion("missing price".to_string()));
1101        assert_eq!(code, "no-conversion");
1102        assert_eq!(msg, "missing price");
1103    }
1104
1105    #[test]
1106    fn err_code_falls_back_to_runtime_for_generic_traps() {
1107        let (code, msg) = err_code_and_message(&EngineError::Trap("oops".to_string()));
1108        assert_eq!(code, "runtime");
1109        assert_eq!(msg, "oops");
1110    }
1111
1112    #[test]
1113    fn err_code_maps_out_of_fuel_to_runtime_with_diagnostic_message() {
1114        let (code, msg) = err_code_and_message(&EngineError::OutOfFuel);
1115        assert_eq!(code, "runtime");
1116        assert_eq!(msg, "fuel exhausted");
1117    }
1118
1119    fn store_with_fuel<T: Default>(engine: &Engine, fuel: u64) -> Store<T> {
1120        let mut store = Store::new(engine, T::default());
1121        store
1122            .set_fuel(fuel)
1123            .expect("set_fuel must succeed for fresh store");
1124        store.set_epoch_deadline(1);
1125        store
1126    }
1127
1128    #[test]
1129    fn baseline_engine_omits_fuel() {
1130        let opts = EngineOpts::baseline();
1131        assert!(!opts.fuel);
1132        let _engine = build_engine(opts).expect("baseline engine must build");
1133    }
1134
1135    #[test]
1136    fn with_fuel_engine_supports_set_fuel() {
1137        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1138        let mut store: Store<()> = Store::new(&engine, ());
1139        store
1140            .set_fuel(1_000)
1141            .expect("set_fuel works only when consume_fuel is on");
1142    }
1143
1144    #[test]
1145    fn module_cache_returns_same_module_for_same_bytecode() {
1146        let engine = build_engine(EngineOpts::baseline()).unwrap();
1147        let cache = ModuleCache::new();
1148        let wat = r#"(module (func (export "answer") (result i64) (i64.const 42)))"#;
1149        let bytes = wat::parse_str(wat).unwrap();
1150        assert_eq!(cache.len().unwrap(), 0);
1151        let _first = cache.get_or_compile(&engine, &bytes).unwrap();
1152        assert_eq!(cache.len().unwrap(), 1);
1153        let _second = cache.get_or_compile(&engine, &bytes).unwrap();
1154        assert_eq!(cache.len().unwrap(), 1);
1155    }
1156
1157    #[test]
1158    fn module_cache_clones_share_storage() {
1159        let engine = build_engine(EngineOpts::baseline()).unwrap();
1160        let cache_a = ModuleCache::new();
1161        let cache_b = cache_a.clone();
1162        let wat = r#"(module (func (export "answer") (result i64) (i64.const 42)))"#;
1163        let bytes = wat::parse_str(wat).unwrap();
1164        let _ = cache_a.get_or_compile(&engine, &bytes).unwrap();
1165        assert_eq!(cache_b.len().unwrap(), 1);
1166    }
1167
1168    #[test]
1169    fn runs_trivial_i64_export() {
1170        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1171        let module = compile_wat(
1172            &engine,
1173            r#"(module (func (export "answer") (result i64) (i64.const 42)))"#,
1174        )
1175        .unwrap();
1176        let mut store: Store<()> = store_with_fuel(&engine, 100_000);
1177        let result = call_i64_export(&engine, &mut store, &module, "answer").unwrap();
1178        assert_eq!(result, 42);
1179    }
1180
1181    #[test]
1182    fn missing_export_returns_typed_error() {
1183        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1184        let module = compile_wat(
1185            &engine,
1186            r#"(module (func (export "answer") (result i64) (i64.const 42)))"#,
1187        )
1188        .unwrap();
1189        let mut store: Store<()> = store_with_fuel(&engine, 100_000);
1190        let err = call_i64_export(&engine, &mut store, &module, "missing").unwrap_err();
1191        assert!(matches!(err, EngineError::MissingExport(name) if name == "missing"));
1192    }
1193
1194    #[test]
1195    fn fuel_exhaustion_yields_typed_error() {
1196        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1197        let module = compile_wat(
1198            &engine,
1199            r#"
1200            (module
1201              (func (export "spin") (result i64)
1202                (loop (br 0))
1203                (i64.const 0)))
1204            "#,
1205        )
1206        .unwrap();
1207        let mut store: Store<()> = store_with_fuel(&engine, 1_000);
1208        let err = call_i64_export(&engine, &mut store, &module, "spin").unwrap_err();
1209        assert!(matches!(err, EngineError::OutOfFuel), "got: {err:?}");
1210    }
1211
1212    #[test]
1213    fn epoch_interrupt_yields_typed_error() {
1214        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1215        let module = compile_wat(
1216            &engine,
1217            r#"
1218            (module
1219              (func (export "spin") (result i64)
1220                (loop (br 0))
1221                (i64.const 0)))
1222            "#,
1223        )
1224        .unwrap();
1225        let mut store: Store<()> = Store::new(&engine, ());
1226        store.set_fuel(1_000_000_000).unwrap();
1227        store.set_epoch_deadline(1);
1228        engine.increment_epoch();
1229        engine.increment_epoch();
1230        let err = call_i64_export(&engine, &mut store, &module, "spin").unwrap_err();
1231        assert!(
1232            matches!(err, EngineError::EpochInterrupt | EngineError::OutOfFuel),
1233            "got: {err:?}"
1234        );
1235    }
1236
1237    #[test]
1238    fn malformed_module_bytes_yield_compile_error() {
1239        let engine = build_engine(EngineOpts::baseline()).unwrap();
1240        let err = compile_module(&engine, b"not wasm bytes").unwrap_err();
1241        assert!(matches!(err, EngineError::Compile(_)));
1242    }
1243
1244    #[tokio::test(flavor = "current_thread")]
1245    async fn alloc_pair_chain_builds_list_head_in_order() {
1246        use wasmtime::I31;
1247
1248        // Self-recursive $pair shape matching `CompileContext::new_skeleton`.
1249        // The module exports `pair_new` (the helper alloc_pair_chain re-enters
1250        // for each element) and a `go` entry that asks the test host fn for a
1251        // 3-element chain, then walks it to confirm the host-built structure.
1252        let wat = r#"
1253        (module
1254          (rec
1255            (type $pair (struct (field anyref) (field (ref null $pair)))))
1256          (import "test" "make_chain"
1257            (func $make_chain (result (ref null struct))))
1258          (func $pair_new (export "pair_new")
1259            (param $car anyref) (param $cdr (ref null $pair))
1260            (result (ref null $pair))
1261            (struct.new $pair (local.get $car) (local.get $cdr)))
1262          (func $length (param $head (ref null $pair)) (result i32)
1263            (local $count i32)
1264            (block $exit
1265              (loop $more
1266                (br_if $exit (ref.is_null (local.get $head)))
1267                (local.set $count (i32.add (local.get $count) (i32.const 1)))
1268                (local.set $head
1269                  (struct.get $pair 1 (local.get $head)))
1270                (br $more)))
1271            (local.get $count))
1272          (func (export "go") (result i32)
1273            (local $head (ref null $pair))
1274            (local.set $head
1275              (ref.cast (ref null $pair) (call $make_chain)))
1276            (call $length (local.get $head))))
1277        "#;
1278
1279        let engine = build_engine(EngineOpts::baseline()).unwrap();
1280        let module = compile_wat(&engine, wat).unwrap();
1281        let mut linker: Linker<()> = Linker::new(&engine);
1282        linker
1283            .func_wrap_async("test", "make_chain", |mut caller: Caller<'_, ()>, ()| {
1284                Box::new(async move {
1285                    let items: Vec<Rooted<AnyRef>> = (0..3)
1286                        .map(|i| AnyRef::from_i31(caller.as_context_mut(), I31::wrapping_u32(i)))
1287                        .collect();
1288                    alloc_pair_chain(&mut caller, items).await
1289                })
1290            })
1291            .unwrap();
1292        let mut store: Store<()> = Store::new(&engine, ());
1293        store.set_epoch_deadline(1_000);
1294        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
1295        let go = instance.get_func(&mut store, "go").unwrap();
1296        let mut results = [Val::I32(0)];
1297        go.call_async(&mut store, &[], &mut results).await.unwrap();
1298        assert_eq!(results[0].i32(), Some(3));
1299    }
1300
1301    #[tokio::test(flavor = "current_thread")]
1302    async fn alloc_pair_chain_errors_without_pair_new_export() {
1303        use wasmtime::Func;
1304
1305        // No `pair_new` export — the host fn must surface the missing-export
1306        // contract violation rather than panic or silently succeed.
1307        let wat = r#"
1308        (module
1309          (import "test" "try_chain"
1310            (func $try))
1311          (func (export "go") (call $try)))
1312        "#;
1313        let engine = build_engine(EngineOpts::baseline()).unwrap();
1314        let module = compile_wat(&engine, wat).unwrap();
1315        let mut linker: Linker<()> = Linker::new(&engine);
1316        linker
1317            .func_wrap_async("test", "try_chain", |mut caller: Caller<'_, ()>, ()| {
1318                Box::new(async move {
1319                    let empty: Vec<Rooted<AnyRef>> = Vec::new();
1320                    let result = alloc_pair_chain(&mut caller, empty).await;
1321                    match result {
1322                        Err(e) => {
1323                            let msg = e.to_string();
1324                            assert!(
1325                                msg.contains("pair_new"),
1326                                "expected pair_new-missing error, got: {msg}"
1327                            );
1328                            Ok(())
1329                        }
1330                        Ok(_) => Err(wasmtime::Error::msg(
1331                            "alloc_pair_chain unexpectedly succeeded without pair_new",
1332                        )),
1333                    }
1334                })
1335            })
1336            .unwrap();
1337        let mut store: Store<()> = Store::new(&engine, ());
1338        store.set_epoch_deadline(1_000);
1339        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
1340        let go: Func = instance.get_func(&mut store, "go").unwrap();
1341        let mut results: [Val; 0] = [];
1342        go.call_async(&mut store, &[], &mut results).await.unwrap();
1343    }
1344
1345    #[tokio::test(flavor = "current_thread")]
1346    async fn alloc_commodity_ref_builds_atomic_via_reentry() {
1347        // ADR-0028 E0: the host builds a commodity by re-entering the module's
1348        // exported `commodity_new`, which writes a NULL unit-term (= atomic).
1349        // The `$commodity` shape matches `CompileContext::new_skeleton` (5
1350        // fields, the 5th a `(ref null $unit_term)`). `go` asks the host for a
1351        // 7/2 commodity with UUID hi=1/lo=2, then reads numer, hi, lo, and
1352        // whether the term is null.
1353        let wat = r#"
1354        (module
1355          (type $unit_term (array (mut i64)))
1356          (type $commodity
1357            (struct (field i64) (field i64) (field i64) (field i64)
1358                    (field (ref null $unit_term))))
1359          (import "test" "make_commodity"
1360            (func $make_commodity (result (ref null struct))))
1361          (func $commodity_new (export "commodity_new")
1362            (param $n i64) (param $d i64) (param $hi i64) (param $lo i64)
1363            (result (ref $commodity))
1364            (struct.new $commodity
1365              (local.get $n) (local.get $d) (local.get $hi) (local.get $lo)
1366              (ref.null $unit_term)))
1367          (func (export "go") (result i64 i64 i64 i32)
1368            (local $c (ref $commodity))
1369            (local.set $c
1370              (ref.cast (ref $commodity) (call $make_commodity)))
1371            (struct.get $commodity 0 (local.get $c))
1372            (struct.get $commodity 2 (local.get $c))
1373            (struct.get $commodity 3 (local.get $c))
1374            (ref.is_null (struct.get $commodity 4 (local.get $c)))))
1375        "#;
1376
1377        let engine = build_engine(EngineOpts::baseline()).unwrap();
1378        let module = compile_wat(&engine, wat).unwrap();
1379        let mut linker: Linker<()> = Linker::new(&engine);
1380        linker
1381            .func_wrap_async(
1382                "test",
1383                "make_commodity",
1384                |mut caller: Caller<'_, ()>, ()| {
1385                    Box::new(async move {
1386                        let id = Uuid::from_u128((1u128 << 64) | 2u128);
1387                        Ok(Some(alloc_commodity_ref(&mut caller, 7, 2, id).await?))
1388                    })
1389                },
1390            )
1391            .unwrap();
1392        let mut store: Store<()> = Store::new(&engine, ());
1393        store.set_epoch_deadline(1_000);
1394        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
1395        let go = instance.get_func(&mut store, "go").unwrap();
1396        let mut results = [Val::I64(0), Val::I64(0), Val::I64(0), Val::I32(0)];
1397        go.call_async(&mut store, &[], &mut results).await.unwrap();
1398        assert_eq!(results[0].i64(), Some(7), "numer");
1399        assert_eq!(results[1].i64(), Some(1), "commodity_hi");
1400        assert_eq!(results[2].i64(), Some(2), "commodity_lo");
1401        assert_eq!(results[3].i32(), Some(1), "atomic ⇒ null unit-term");
1402    }
1403
1404    #[tokio::test(flavor = "current_thread")]
1405    async fn alloc_commodity_ref_errors_without_commodity_new_export() {
1406        use wasmtime::Func;
1407
1408        // No `commodity_new` export — the host fn must surface the missing-
1409        // export contract violation rather than panic or silently succeed.
1410        let wat = r#"
1411        (module
1412          (import "test" "try_make"
1413            (func $try))
1414          (func (export "go") (call $try)))
1415        "#;
1416        let engine = build_engine(EngineOpts::baseline()).unwrap();
1417        let module = compile_wat(&engine, wat).unwrap();
1418        let mut linker: Linker<()> = Linker::new(&engine);
1419        linker
1420            .func_wrap_async("test", "try_make", |mut caller: Caller<'_, ()>, ()| {
1421                Box::new(async move {
1422                    let id = Uuid::from_u128(0);
1423                    match alloc_commodity_ref(&mut caller, 1, 1, id).await {
1424                        Err(e) => {
1425                            let msg = e.to_string();
1426                            assert!(
1427                                msg.contains("commodity_new"),
1428                                "expected commodity_new-missing error, got: {msg}"
1429                            );
1430                            Ok(())
1431                        }
1432                        Ok(_) => Err(wasmtime::Error::msg(
1433                            "alloc_commodity_ref unexpectedly succeeded without commodity_new",
1434                        )),
1435                    }
1436                })
1437            })
1438            .unwrap();
1439        let mut store: Store<()> = Store::new(&engine, ());
1440        store.set_epoch_deadline(1_000);
1441        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
1442        let go: Func = instance.get_func(&mut store, "go").unwrap();
1443        let mut results: [Val; 0] = [];
1444        go.call_async(&mut store, &[], &mut results).await.unwrap();
1445    }
1446
1447    #[test]
1448    fn unit_term_algebra_merges_sorts_and_cancels() {
1449        use nomiscript::{Compiler, Reader, SymbolTable};
1450
1451        fn ar(a: Rooted<AnyRef>) -> Val {
1452            Val::AnyRef(Some(a))
1453        }
1454
1455        // Compile a trivial program just to obtain the skeleton, which exports
1456        // the unit-term helpers. Then exercise the sorted-merge directly: it is
1457        // the riskiest hand-written wasm in ADR-0028 E1/E2.
1458        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1459        let mut compiler = Compiler::new();
1460        let mut symbols = SymbolTable::with_builtins();
1461        let program = Reader::parse("0").unwrap();
1462        let (bytes, _) = compiler
1463            .compile_eval_with_type(&program, &mut symbols)
1464            .expect("eval compile");
1465        let module = compile_module(&engine, &bytes).expect("module");
1466        let mut linker: Linker<()> = Linker::new(&engine);
1467        link_nomi_raise_stub(&mut linker, &engine);
1468        let mut store: Store<()> = Store::new(&engine, ());
1469        store.set_fuel(100_000_000).unwrap();
1470        store.set_epoch_deadline(1);
1471        let instance = linker.instantiate(&mut store, &module).unwrap();
1472
1473        let call_ref = |store: &mut Store<()>, name: &str, args: &[Val]| -> Rooted<AnyRef> {
1474            let f = instance.get_func(&mut *store, name).unwrap();
1475            let mut res = [Val::AnyRef(None)];
1476            f.call(&mut *store, args, &mut res).unwrap();
1477            match &res[0] {
1478                Val::AnyRef(Some(a)) => *a,
1479                other => panic!("{name} returned {other:?}"),
1480            }
1481        };
1482        let read_term = |store: &mut Store<()>, t: Rooted<AnyRef>| -> Vec<i64> {
1483            let arr = t.unwrap_array(&mut *store).unwrap();
1484            let len = arr.len(&mut *store).unwrap();
1485            (0..len)
1486                .map(|i| arr.get(&mut *store, i).unwrap().i64().unwrap())
1487                .collect()
1488        };
1489        let singleton = |store: &mut Store<()>, hi: i64, lo: i64| -> Rooted<AnyRef> {
1490            call_ref(store, "unit_singleton", &[Val::I64(hi), Val::I64(lo)])
1491        };
1492
1493        let usd = singleton(&mut store, 10, 20);
1494        let eur = singleton(&mut store, 30, 40);
1495        assert_eq!(read_term(&mut store, usd), vec![10, 20, 1]);
1496        assert_eq!(read_term(&mut store, eur), vec![30, 40, 1]);
1497
1498        // Disjoint merge is sorted by (hi,lo), order-independent.
1499        let usd_eur = call_ref(&mut store, "unit_mul", &[ar(usd), ar(eur)]);
1500        assert_eq!(read_term(&mut store, usd_eur), vec![10, 20, 1, 30, 40, 1]);
1501        let eur_usd = call_ref(&mut store, "unit_mul", &[ar(eur), ar(usd)]);
1502        assert_eq!(read_term(&mut store, eur_usd), vec![10, 20, 1, 30, 40, 1]);
1503
1504        // Matching key sums exponents.
1505        let usd2 = call_ref(&mut store, "unit_mul", &[ar(usd), ar(usd)]);
1506        assert_eq!(read_term(&mut store, usd2), vec![10, 20, 2]);
1507
1508        // Cancellation drops the zero-exponent entry → empty (dimensionless).
1509        let canceled = call_ref(&mut store, "unit_div", &[ar(usd), ar(usd)]);
1510        assert_eq!(read_term(&mut store, canceled), Vec::<i64>::new());
1511
1512        // negate flips exponents.
1513        let neg_usd = call_ref(&mut store, "unit_negate", &[ar(usd)]);
1514        assert_eq!(read_term(&mut store, neg_usd), vec![10, 20, -1]);
1515
1516        let eq = |store: &mut Store<()>, a: Rooted<AnyRef>, b: Rooted<AnyRef>| -> i32 {
1517            let f = instance.get_func(&mut *store, "unit_eq").unwrap();
1518            let mut res = [Val::I32(0)];
1519            f.call(&mut *store, &[ar(a), ar(b)], &mut res).unwrap();
1520            res[0].i32().unwrap()
1521        };
1522        assert_eq!(eq(&mut store, usd, usd), 1);
1523        assert_eq!(eq(&mut store, usd, eur), 0);
1524        // Same multiset, built two ways, compares equal.
1525        assert_eq!(eq(&mut store, usd_eur, eur_usd), 1);
1526    }
1527
1528    #[tokio::test(flavor = "current_thread")]
1529    async fn compound_money_arithmetic_end_to_end() {
1530        use nomiscript::{Compiler, HostFnSpec, Reader, SymbolTable, WasmType};
1531
1532        // Two distinct currencies, each produced atomic at 3/1 by a host fn.
1533        const USD: u128 = 0x1111_1111_1111_1111_2222_2222_2222_2222;
1534        const EUR: u128 = 0x3333_3333_3333_3333_4444_4444_4444_4444;
1535
1536        async fn run(src: &str) -> Result<EvalValue, String> {
1537            let specs = vec![
1538                HostFnSpec::new("usd", "test", "usd").returns(WasmType::Commodity),
1539                HostFnSpec::new("eur", "test", "eur").returns(WasmType::Commodity),
1540                HostFnSpec::new("sink", "test", "sink")
1541                    .with_params(vec![WasmType::Commodity])
1542                    .returns(WasmType::I32),
1543            ];
1544            let program = Reader::parse(src).unwrap();
1545            let mut compiler = Compiler::with_host_fns(specs.clone());
1546            let mut symbols = SymbolTable::with_builtins();
1547            symbols.register_host_fns(&specs);
1548            let (bytes, result_ty) = compiler
1549                .compile_eval_with_type(&program, &mut symbols)
1550                .map_err(|e| e.to_string())?;
1551            let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1552            let module = compile_module(&engine, &bytes).map_err(|e| format!("{e:?}"))?;
1553            let mut linker: Linker<()> = Linker::new(&engine);
1554            link_nomi_raise_stub(&mut linker, &engine);
1555            linker
1556                .func_wrap_async("test", "usd", |mut caller: Caller<'_, ()>, ()| {
1557                    Box::new(async move {
1558                        Ok(Some(
1559                            alloc_commodity_ref(&mut caller, 3, 1, Uuid::from_u128(USD)).await?,
1560                        ))
1561                    })
1562                })
1563                .unwrap();
1564            linker
1565                .func_wrap_async("test", "eur", |mut caller: Caller<'_, ()>, ()| {
1566                    Box::new(async move {
1567                        Ok(Some(
1568                            alloc_commodity_ref(&mut caller, 3, 1, Uuid::from_u128(EUR)).await?,
1569                        ))
1570                    })
1571                })
1572                .unwrap();
1573            // The sink only reaches its body for ATOMIC args — a compound arg is
1574            // rejected by the `commodity_assert_atomic` guard before this runs.
1575            linker
1576                .func_wrap_async(
1577                    "test",
1578                    "sink",
1579                    |mut caller: Caller<'_, ()>, (arg,): (Option<Rooted<StructRef>>,)| {
1580                        Box::new(async move {
1581                            read_commodity_arg(&mut caller, arg)?;
1582                            Ok(0i32)
1583                        })
1584                    },
1585                )
1586                .unwrap();
1587            let mut store: Store<()> = Store::new(&engine, ());
1588            store.set_fuel(1_000_000_000).unwrap();
1589            store.set_epoch_deadline(1);
1590            let instance = linker
1591                .instantiate_async(&mut store, &module)
1592                .await
1593                .map_err(|e| format!("{e:?}"))?;
1594            let func = instance.get_func(&mut store, "nomi-eval").unwrap();
1595            let mut results = [Val::AnyRef(None)];
1596            func.call_async(&mut store, &[], &mut results)
1597                .await
1598                .map_err(|e| e.to_string())?;
1599            let any = match &results[0] {
1600                Val::AnyRef(a) => *a,
1601                _ => return Err("nomi-eval returned non-anyref".to_string()),
1602            };
1603            decode_eval_result(&mut store, any, result_ty).map_err(|e| e.to_string())
1604        }
1605
1606        // money ÷ money, same currency → dimensionless → decodes as a Number.
1607        assert_eq!(
1608            run("(/ (usd) (usd))").await,
1609            Ok(EvalValue::Ratio { numer: 1, denom: 1 })
1610        );
1611        // money + money, same currency → atomic money (the null-term fast path).
1612        match run("(+ (usd) (usd))").await {
1613            Ok(EvalValue::Commodity { numer, denom, .. }) => assert_eq!((numer, denom), (6, 1)),
1614            other => panic!("expected atomic commodity 6/1, got {other:?}"),
1615        }
1616        // money + money, different currency → COMMODITY-MISMATCH throw.
1617        assert!(run("(+ (usd) (eur))").await.is_err());
1618        // money × money → compound, no host wire form yet → decode error.
1619        assert!(
1620            run("(* (usd) (usd))")
1621                .await
1622                .unwrap_err()
1623                .contains("compound")
1624        );
1625        // an ATOMIC money passes the host-border guard.
1626        assert_eq!(run("(sink (usd))").await, Ok(EvalValue::I32(0)));
1627        // a COMPOUND money is rejected at the host border by the guard.
1628        assert!(run("(sink (* (usd) (usd)))").await.is_err());
1629        // a COMPOUND money riding a $pair cell is rejected by the pair-car
1630        // renderer too — it must NOT slip through as id-zero atomic money
1631        // (the pair-decode border is field-4-aware, same as the top-level one).
1632        assert!(
1633            run("(list (* (usd) (usd)))")
1634                .await
1635                .unwrap_err()
1636                .contains("compound")
1637        );
1638        // an atomic money in a list cell still renders (sanity: the field-4
1639        // gate doesn't reject the null-term atomic case).
1640        assert!(run("(list (usd))").await.is_ok());
1641        // dimensionless × atomic → a `[(usd,1)]` singleton term that
1642        // `commodity_new_with_term` canonicalizes back to ATOMIC usd: it decodes
1643        // as an atomic commodity (3/1) and passes the host-border atomic guard.
1644        match run("(* (/ (usd) (usd)) (usd))").await {
1645            Ok(EvalValue::Commodity { numer, denom, .. }) => assert_eq!((numer, denom), (3, 1)),
1646            other => panic!("expected atomic commodity 3/1, got {other:?}"),
1647        }
1648        assert_eq!(
1649            run("(sink (* (/ (usd) (usd)) (usd)))").await,
1650            Ok(EvalValue::I32(0))
1651        );
1652    }
1653
1654    /// The eval-mode `CompileContext` declares `nomi.__nomi_raise` for
1655    /// `(error 'code "msg")` lowering even when no `(error)` form is
1656    /// present in the program. The host side lives in the rpc crate;
1657    /// for the scripting-crate runtime tests we link a never-called
1658    /// stub so `instantiate()` resolves the import.
1659    fn link_nomi_raise_stub(linker: &mut Linker<()>, engine: &wasmtime::Engine) {
1660        linker
1661            .func_new(
1662                "nomi",
1663                "__nomi_raise",
1664                wasmtime::FuncType::new(
1665                    engine,
1666                    [
1667                        wasmtime::ValType::Ref(wasmtime::RefType::ARRAYREF),
1668                        wasmtime::ValType::Ref(wasmtime::RefType::ARRAYREF),
1669                    ],
1670                    [],
1671                ),
1672                |_, _, _| {
1673                    Err(wasmtime::Error::msg(
1674                        "__nomi_raise stub: not linked in this test",
1675                    ))
1676                },
1677            )
1678            .unwrap();
1679        link_log_stub(linker, engine);
1680        link_nomi_catch_each_stub(linker, engine);
1681    }
1682
1683    /// `env.log` `(i32 level, i32 ptr, i32 len) -> ()` stub. Eval-mode modules
1684    /// import `env.log` (PRINT / DISPLAY / NEWLINE / DEBUG lower to it); a test
1685    /// linker that instantiates an eval module must define it or instantiation
1686    /// fails with "unknown import". Production wires it via `scripting::host`
1687    /// (script mode) / `rpc::natives::env_io` (eval mode); this no-op stub
1688    /// suffices for tests that don't assert on logged output.
1689    fn link_log_stub(linker: &mut Linker<()>, engine: &wasmtime::Engine) {
1690        linker
1691            .func_new(
1692                "env",
1693                "log",
1694                wasmtime::FuncType::new(
1695                    engine,
1696                    [
1697                        wasmtime::ValType::I32,
1698                        wasmtime::ValType::I32,
1699                        wasmtime::ValType::I32,
1700                    ],
1701                    [],
1702                ),
1703                |_, _, _| Ok(()),
1704            )
1705            .unwrap();
1706    }
1707
1708    /// Companion to `link_nomi_raise_stub`. The eval compile context now
1709    /// declares `__nomi_catch_each` up-front (so its import index is
1710    /// stable before any user host fn is wired — matches `__nomi_raise`'s
1711    /// shape), so even programs that don't use `(catch-each ...)` still
1712    /// need the import resolvable at instantiation time. The stub traps
1713    /// on call so a regression that accidentally invokes catch-each in
1714    /// these unit tests surfaces loudly rather than silently no-oping.
1715    fn link_nomi_catch_each_stub(linker: &mut Linker<()>, engine: &wasmtime::Engine) {
1716        let abstract_struct =
1717            wasmtime::ValType::Ref(wasmtime::RefType::new(true, wasmtime::HeapType::Struct));
1718        let funcref = wasmtime::ValType::Ref(wasmtime::RefType::FUNCREF);
1719        let anyref = wasmtime::ValType::Ref(wasmtime::RefType::ANYREF);
1720        linker
1721            .func_new(
1722                "nomi",
1723                "__nomi_catch_each",
1724                wasmtime::FuncType::new(
1725                    engine,
1726                    [funcref, anyref, abstract_struct.clone()],
1727                    [abstract_struct],
1728                ),
1729                |_, _, _| {
1730                    Err(wasmtime::Error::msg(
1731                        "__nomi_catch_each stub: not linked in this test",
1732                    ))
1733                },
1734            )
1735            .unwrap();
1736    }
1737
1738    /// End-to-end: nomiscript Compiler emits eval-mode bytecode that
1739    /// returns the form's final value via nomi-eval's `(ref null any)`
1740    /// return slot, the runtime instantiates it (no capture host fns
1741    /// linked — they retired in A6.c), and `decode_eval_result` walks
1742    /// the anyref into the structured `EvalValue` the rest of the host
1743    /// renders from.
1744    fn run_nomiscript_eval(program: &nomiscript::Program) -> Option<EvalValue> {
1745        use nomiscript::{Compiler, SymbolTable};
1746        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1747        let mut compiler = Compiler::new();
1748        let mut symbols = SymbolTable::with_builtins();
1749        let (bytes, result_ty) = compiler
1750            .compile_eval_with_type(program, &mut symbols)
1751            .expect("eval compile");
1752        let module = compile_module(&engine, &bytes).expect("module");
1753        let mut linker: Linker<()> = Linker::new(&engine);
1754        link_nomi_raise_stub(&mut linker, &engine);
1755        let mut store: Store<()> = Store::new(&engine, ());
1756        store.set_fuel(10_000_000).unwrap();
1757        store.set_epoch_deadline(1);
1758        let instance = linker.instantiate(&mut store, &module).unwrap();
1759        let func = instance.get_func(&mut store, "nomi-eval").unwrap();
1760        let mut results = [Val::AnyRef(None)];
1761        func.call(&mut store, &[], &mut results).unwrap();
1762        let any = match &results[0] {
1763            Val::AnyRef(a) => *a,
1764            _ => panic!("nomi-eval returned non-anyref"),
1765        };
1766        Some(decode_eval_result(&mut store, any, result_ty).expect("decode"))
1767    }
1768
1769    #[test]
1770    fn nomiscript_eval_captures_integer_literal() {
1771        use nomiscript::{Expr, Fraction, Program};
1772        // ADR-0028: an integer literal is an Index (I32), decoding as `I32`,
1773        // not the dimensionless `Ratio` it conflated with before the flip.
1774        let program = Program::new(vec![Expr::Number(Fraction::from_integer(7))]);
1775        assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::I32(7)));
1776    }
1777
1778    #[test]
1779    fn nomiscript_eval_captures_arithmetic_result() {
1780        use nomiscript::{Expr, Fraction, Program};
1781        // All-integer (Index) arithmetic stays in the Index stratum: `(+ 1 2)`
1782        // decodes as `I32(3)`.
1783        let program = Program::new(vec![Expr::List(vec![
1784            Expr::Symbol("+".into()),
1785            Expr::Number(Fraction::from_integer(1)),
1786            Expr::Number(Fraction::from_integer(2)),
1787        ])]);
1788        assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::I32(3)));
1789    }
1790
1791    #[test]
1792    fn nomiscript_eval_captures_fractional_result() {
1793        use nomiscript::{Expr, Fraction, Program};
1794        // A Scalar operand keeps rational division: `(/ 1/2 2) → 1/4`, decoding
1795        // as `Ratio`. (All-integer `(/ 1 4)` would be Index `0`.)
1796        let program = Program::new(vec![Expr::List(vec![
1797            Expr::Symbol("/".into()),
1798            Expr::Number(Fraction::new(1, 2)),
1799            Expr::Number(Fraction::from_integer(2)),
1800        ])]);
1801        assert_eq!(
1802            run_nomiscript_eval(&program),
1803            Some(EvalValue::Ratio { numer: 1, denom: 4 })
1804        );
1805    }
1806
1807    #[test]
1808    fn nomiscript_eval_captures_nil_for_empty_program() {
1809        let program = nomiscript::Program::default();
1810        assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::Nil));
1811    }
1812
1813    #[test]
1814    fn nomiscript_eval_decodes_bool_as_bool() {
1815        use nomiscript::{Expr, Program};
1816        let program = Program::new(vec![Expr::Bool(true)]);
1817        // `#t` carries `WasmType::Bool` (i31-boxed); the decoder surfaces a
1818        // truthy bool as `Bool(true)` (a falsy one would be `Nil`), not the
1819        // raw integer the old i32-conflated path produced.
1820        assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::Bool(true)));
1821    }
1822
1823    /// Drift-detector: compile-eval each script source, run it, and
1824    /// verify the static `result_ty` hint reported by
1825    /// `compile_eval_with_type` matches the decoded `EvalValue`'s
1826    /// variant. Catches eval-vs-codegen drift across the compiler —
1827    /// the exact bug class that produced the tag-sync test regressions.
1828    /// Add a row whenever a new WasmType or Expr-shape is supported.
1829    #[test]
1830    fn nomiscript_eval_type_hint_matches_value_variant() {
1831        use nomiscript::{Compiler, Program, Reader, SymbolTable, WasmType};
1832        let cases: &[(&str, Option<WasmType>)] = &[
1833            // ADR-0028: integer literals + all-integer arithmetic are Index
1834            // (I32); a fractional literal is Scalar (Ratio).
1835            ("42", Some(WasmType::I32)),
1836            ("(+ 1 2)", Some(WasmType::I32)),
1837            ("(/ 1 4)", Some(WasmType::I32)),
1838            ("(/ 1/2 2)", Some(WasmType::Ratio)),
1839            ("(= 1 1)", Some(WasmType::Bool)),
1840            ("(< 1 2)", Some(WasmType::Bool)),
1841            ("#t", Some(WasmType::Bool)),
1842            ("\"hello\"", Some(WasmType::StringRef)),
1843            ("(let ((x 1)) (+ x 1))", Some(WasmType::I32)),
1844            ("(let ((x 1)) \"tail\")", Some(WasmType::StringRef)),
1845            ("(if (= 1 1) 2 3)", Some(WasmType::I32)),
1846        ];
1847        for (src, expected_ty) in cases {
1848            let program: Program = Reader::parse(src).expect("parse");
1849            let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1850            let mut compiler = Compiler::new();
1851            let mut symbols = SymbolTable::with_builtins();
1852            let (bytes, result_ty) = compiler
1853                .compile_eval_with_type(&program, &mut symbols)
1854                .unwrap_or_else(|e| panic!("compile {src:?}: {e}"));
1855            assert_eq!(
1856                &result_ty, expected_ty,
1857                "compile_eval_with_type reported wrong static type for {src:?}",
1858            );
1859            let module = compile_module(&engine, &bytes).expect("module");
1860            let mut linker: Linker<()> = Linker::new(&engine);
1861            link_nomi_raise_stub(&mut linker, &engine);
1862            let mut store: Store<()> = Store::new(&engine, ());
1863            store.set_fuel(10_000_000).unwrap();
1864            store.set_epoch_deadline(1);
1865            let instance = linker.instantiate(&mut store, &module).unwrap();
1866            let func = instance.get_func(&mut store, "nomi-eval").unwrap();
1867            let mut results = [Val::AnyRef(None)];
1868            func.call(&mut store, &[], &mut results)
1869                .unwrap_or_else(|e| panic!("run {src:?}: {e}"));
1870            let any = match &results[0] {
1871                Val::AnyRef(a) => *a,
1872                _ => panic!("nomi-eval returned non-anyref for {src:?}"),
1873            };
1874            let decoded = decode_eval_result(&mut store, any, result_ty)
1875                .unwrap_or_else(|e| panic!("decode {src:?}: {e}"));
1876            // The mapping below is the canonical EvalValue ↔ WasmType
1877            // contract. Any drift fails here.
1878            let ok = matches!(
1879                (&result_ty, &decoded),
1880                (None, EvalValue::Nil)
1881                    | (Some(WasmType::I32), EvalValue::I32(_))
1882                    // A Bool decodes to Bool(true) when truthy or Nil when falsy.
1883                    | (Some(WasmType::Bool), EvalValue::Bool(_) | EvalValue::Nil)
1884                    | (Some(WasmType::Ratio), EvalValue::Ratio { .. })
1885                    | (Some(WasmType::Commodity), EvalValue::Commodity { .. })
1886                    | (
1887                        Some(WasmType::StringRef),
1888                        EvalValue::String(_) | EvalValue::Bytes(_)
1889                    ),
1890            );
1891            assert!(
1892                ok,
1893                "type/value drift for {src:?}: hint={result_ty:?}, decoded={decoded:?}",
1894            );
1895        }
1896    }
1897
1898    #[tokio::test(flavor = "current_thread")]
1899    async fn render_entity_emits_named_field_plist() {
1900        // Build a $commodity-shaped struct (3 string fields, matching the
1901        // Commodity layout's id/symbol/name slots) via WAT, then decode it with
1902        // `render_entity` — the same call the host eval path makes for a returned
1903        // entity. Proves the spec-driven decoder reads each slot by name.
1904        let wat = r#"
1905        (module
1906          (type $i8 (array (mut i8)))
1907          (type $commodity (struct
1908            (field (ref null $i8))
1909            (field (ref null $i8))
1910            (field (ref null $i8))))
1911          (data $id "uuid-123")
1912          (data $sym "USD")
1913          (data $name "US Dollar")
1914          (func (export "go") (result (ref null struct))
1915            (struct.new $commodity
1916              (array.new_data $i8 $id  (i32.const 0) (i32.const 8))
1917              (array.new_data $i8 $sym (i32.const 0) (i32.const 3))
1918              (array.new_data $i8 $name (i32.const 0) (i32.const 9)))))
1919        "#;
1920        let engine = build_engine(EngineOpts::baseline()).unwrap();
1921        let module = compile_wat(&engine, wat).unwrap();
1922        let linker: Linker<()> = Linker::new(&engine);
1923        let mut store: Store<()> = Store::new(&engine, ());
1924        store.set_epoch_deadline(1_000);
1925        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
1926        let go = instance.get_func(&mut store, "go").unwrap();
1927        let mut results = [Val::AnyRef(None)];
1928        go.call_async(&mut store, &[], &mut results).await.unwrap();
1929        let entity = match results[0] {
1930            Val::AnyRef(Some(a)) => a.unwrap_struct(&mut store).unwrap(),
1931            other => panic!("go did not return a struct: {other:?}"),
1932        };
1933        let rendered =
1934            render_entity(&mut store, entity, nomiscript::EntityKind::Commodity).unwrap();
1935        assert_eq!(
1936            rendered,
1937            "(:commodity :id \"uuid-123\" :symbol \"USD\" :name \"US Dollar\")"
1938        );
1939    }
1940
1941    #[tokio::test(flavor = "current_thread")]
1942    async fn read_entity_string_field_reads_named_slot() {
1943        // Same Commodity-shaped struct; prove the field reader resolves a named
1944        // slot from the layout (id = slot 0, name = slot 2) — the call
1945        // `draft-split` makes to turn an entity ref into a stable uuid string.
1946        let wat = r#"
1947        (module
1948          (type $i8 (array (mut i8)))
1949          (type $commodity (struct
1950            (field (ref null $i8))
1951            (field (ref null $i8))
1952            (field (ref null $i8))))
1953          (data $id "uuid-123")
1954          (data $sym "USD")
1955          (data $name "US Dollar")
1956          (func (export "go") (result (ref null struct))
1957            (struct.new $commodity
1958              (array.new_data $i8 $id  (i32.const 0) (i32.const 8))
1959              (array.new_data $i8 $sym (i32.const 0) (i32.const 3))
1960              (array.new_data $i8 $name (i32.const 0) (i32.const 9)))))
1961        "#;
1962        let engine = build_engine(EngineOpts::baseline()).unwrap();
1963        let module = compile_wat(&engine, wat).unwrap();
1964        let linker: Linker<()> = Linker::new(&engine);
1965        let mut store: Store<()> = Store::new(&engine, ());
1966        store.set_epoch_deadline(1_000);
1967        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
1968        let go = instance.get_func(&mut store, "go").unwrap();
1969        let mut results = [Val::AnyRef(None)];
1970        go.call_async(&mut store, &[], &mut results).await.unwrap();
1971        let entity = match results[0] {
1972            Val::AnyRef(Some(a)) => a.unwrap_struct(&mut store).unwrap(),
1973            other => panic!("go did not return a struct: {other:?}"),
1974        };
1975
1976        let id = read_entity_string_field_ctx(
1977            &mut store,
1978            entity,
1979            nomiscript::EntityKind::Commodity,
1980            "id",
1981        )
1982        .unwrap();
1983        assert_eq!(id, "uuid-123");
1984
1985        let name = read_entity_string_field_ctx(
1986            &mut store,
1987            entity,
1988            nomiscript::EntityKind::Commodity,
1989            "name",
1990        )
1991        .unwrap();
1992        assert_eq!(name, "US Dollar");
1993
1994        // A field that isn't a String slot in the layout is rejected.
1995        let bad = read_entity_string_field_ctx(
1996            &mut store,
1997            entity,
1998            nomiscript::EntityKind::Commodity,
1999            "nonexistent",
2000        );
2001        assert!(bad.is_err());
2002    }
2003}