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(format!("\"{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/// (the recursive `report_node.children`) renders as `()`-elided rather than
995/// walking the tree, since the host renderer has no element-type context there.
996fn render_entity(
997    mut store: impl AsContextMut,
998    entity: Rooted<StructRef>,
999    kind: nomiscript::EntityKind,
1000) -> wasmtime::Result<String> {
1001    use nomiscript::EntityFieldKind;
1002
1003    let Some(layout) = nomiscript::entity_layout(kind) else {
1004        // No field layout (e.g. Condition isn't a server entity).
1005        return Ok(format!("(:{kind:?})"));
1006    };
1007    let mut out = format!("(:{}", layout.label);
1008    for (slot, field) in layout.fields.iter().enumerate() {
1009        let rendered = match field.kind {
1010            EntityFieldKind::String => read_string_slot(&mut store, entity, slot)?,
1011            EntityFieldKind::Ratio => read_ratio_slot(&mut store, entity, slot)?,
1012            EntityFieldKind::I32 => entity
1013                .field(&mut store, slot)?
1014                .i32()
1015                .unwrap_or(0)
1016                .to_string(),
1017            // Recursive child list (report_node.children): no element-type
1018            // context here, so elide rather than mis-decode.
1019            EntityFieldKind::Pair => "(...)".to_string(),
1020        };
1021        out.push_str(&format!(" :{} {rendered}", field.name));
1022    }
1023    out.push(')');
1024    Ok(out)
1025}
1026
1027/// Reads a `(ref null $i8_array)` string field at `slot`, rendered as a quoted
1028/// literal. A null/empty slot renders as `""`.
1029fn read_string_slot(
1030    mut store: impl AsContextMut,
1031    entity: Rooted<StructRef>,
1032    slot: usize,
1033) -> wasmtime::Result<String> {
1034    match entity.field(&mut store, slot)? {
1035        Val::AnyRef(Some(a)) => {
1036            let arr = a.unwrap_array(&mut store)?;
1037            let len = arr.len(&mut store)?;
1038            let mut bytes = Vec::with_capacity(len as usize);
1039            for i in 0..len {
1040                bytes.push(arr.get(&mut store, i)?.i32().unwrap_or(0) as u8);
1041            }
1042            let s = String::from_utf8(bytes).unwrap_or_else(|_| "<invalid-utf8>".into());
1043            Ok(format!("\"{s}\""))
1044        }
1045        _ => Ok("\"\"".to_string()),
1046    }
1047}
1048
1049/// Reads a `(ref null $ratio)` field at `slot` (i64 numer/denom in slots 0/1 of
1050/// the ratio struct), rendered as `n` or `n/d`. A null slot renders as `0`.
1051fn read_ratio_slot(
1052    mut store: impl AsContextMut,
1053    entity: Rooted<StructRef>,
1054    slot: usize,
1055) -> wasmtime::Result<String> {
1056    match entity.field(&mut store, slot)? {
1057        Val::AnyRef(Some(a)) => {
1058            let s = a.unwrap_struct(&mut store)?;
1059            let n = s.field(&mut store, 0)?.i64().unwrap_or(0);
1060            let d = s.field(&mut store, 1)?.i64().unwrap_or(1);
1061            Ok(if d == 1 {
1062                n.to_string()
1063            } else {
1064                format!("{n}/{d}")
1065            })
1066        }
1067        _ => Ok("0".to_string()),
1068    }
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073    use super::*;
1074
1075    #[test]
1076    fn err_code_uses_script_raised_symbol_verbatim() {
1077        let (code, msg) = err_code_and_message(&EngineError::ScriptRaised {
1078            code: "no-such-account".to_string(),
1079            message: "id=42".to_string(),
1080        });
1081        assert_eq!(code, "no-such-account");
1082        assert_eq!(msg, "id=42");
1083    }
1084
1085    #[test]
1086    fn err_code_maps_commodity_mismatch_script_raise_to_symbol() {
1087        // Commodity mismatch now `throw`s `$nomi_error` in-guest (ADR-0026);
1088        // uncaught, the boundary wrapper bridges it to `__nomi_raise` and the
1089        // classifier yields `ScriptRaised`. The code is the reader-folded
1090        // (upper-cased) symbol `COMMODITY-MISMATCH`, like any script raise —
1091        // `err_code_and_message` passes a `ScriptRaised` code through verbatim.
1092        let (code, msg) = err_code_and_message(&EngineError::ScriptRaised {
1093            code: "COMMODITY-MISMATCH".to_string(),
1094            message: "USD vs EUR".to_string(),
1095        });
1096        assert_eq!(code, "COMMODITY-MISMATCH");
1097        assert_eq!(msg, "USD vs EUR");
1098    }
1099
1100    #[test]
1101    fn err_code_maps_no_conversion_to_kebab_symbol() {
1102        let (code, msg) =
1103            err_code_and_message(&EngineError::NoConversion("missing price".to_string()));
1104        assert_eq!(code, "no-conversion");
1105        assert_eq!(msg, "missing price");
1106    }
1107
1108    #[test]
1109    fn err_code_falls_back_to_runtime_for_generic_traps() {
1110        let (code, msg) = err_code_and_message(&EngineError::Trap("oops".to_string()));
1111        assert_eq!(code, "runtime");
1112        assert_eq!(msg, "oops");
1113    }
1114
1115    #[test]
1116    fn err_code_maps_out_of_fuel_to_runtime_with_diagnostic_message() {
1117        let (code, msg) = err_code_and_message(&EngineError::OutOfFuel);
1118        assert_eq!(code, "runtime");
1119        assert_eq!(msg, "fuel exhausted");
1120    }
1121
1122    fn store_with_fuel<T: Default>(engine: &Engine, fuel: u64) -> Store<T> {
1123        let mut store = Store::new(engine, T::default());
1124        store
1125            .set_fuel(fuel)
1126            .expect("set_fuel must succeed for fresh store");
1127        store.set_epoch_deadline(1);
1128        store
1129    }
1130
1131    #[test]
1132    fn baseline_engine_omits_fuel() {
1133        let opts = EngineOpts::baseline();
1134        assert!(!opts.fuel);
1135        let _engine = build_engine(opts).expect("baseline engine must build");
1136    }
1137
1138    #[test]
1139    fn with_fuel_engine_supports_set_fuel() {
1140        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1141        let mut store: Store<()> = Store::new(&engine, ());
1142        store
1143            .set_fuel(1_000)
1144            .expect("set_fuel works only when consume_fuel is on");
1145    }
1146
1147    #[test]
1148    fn module_cache_returns_same_module_for_same_bytecode() {
1149        let engine = build_engine(EngineOpts::baseline()).unwrap();
1150        let cache = ModuleCache::new();
1151        let wat = r#"(module (func (export "answer") (result i64) (i64.const 42)))"#;
1152        let bytes = wat::parse_str(wat).unwrap();
1153        assert_eq!(cache.len().unwrap(), 0);
1154        let _first = cache.get_or_compile(&engine, &bytes).unwrap();
1155        assert_eq!(cache.len().unwrap(), 1);
1156        let _second = cache.get_or_compile(&engine, &bytes).unwrap();
1157        assert_eq!(cache.len().unwrap(), 1);
1158    }
1159
1160    #[test]
1161    fn module_cache_clones_share_storage() {
1162        let engine = build_engine(EngineOpts::baseline()).unwrap();
1163        let cache_a = ModuleCache::new();
1164        let cache_b = cache_a.clone();
1165        let wat = r#"(module (func (export "answer") (result i64) (i64.const 42)))"#;
1166        let bytes = wat::parse_str(wat).unwrap();
1167        let _ = cache_a.get_or_compile(&engine, &bytes).unwrap();
1168        assert_eq!(cache_b.len().unwrap(), 1);
1169    }
1170
1171    #[test]
1172    fn runs_trivial_i64_export() {
1173        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1174        let module = compile_wat(
1175            &engine,
1176            r#"(module (func (export "answer") (result i64) (i64.const 42)))"#,
1177        )
1178        .unwrap();
1179        let mut store: Store<()> = store_with_fuel(&engine, 100_000);
1180        let result = call_i64_export(&engine, &mut store, &module, "answer").unwrap();
1181        assert_eq!(result, 42);
1182    }
1183
1184    #[test]
1185    fn missing_export_returns_typed_error() {
1186        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1187        let module = compile_wat(
1188            &engine,
1189            r#"(module (func (export "answer") (result i64) (i64.const 42)))"#,
1190        )
1191        .unwrap();
1192        let mut store: Store<()> = store_with_fuel(&engine, 100_000);
1193        let err = call_i64_export(&engine, &mut store, &module, "missing").unwrap_err();
1194        assert!(matches!(err, EngineError::MissingExport(name) if name == "missing"));
1195    }
1196
1197    #[test]
1198    fn fuel_exhaustion_yields_typed_error() {
1199        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1200        let module = compile_wat(
1201            &engine,
1202            r#"
1203            (module
1204              (func (export "spin") (result i64)
1205                (loop (br 0))
1206                (i64.const 0)))
1207            "#,
1208        )
1209        .unwrap();
1210        let mut store: Store<()> = store_with_fuel(&engine, 1_000);
1211        let err = call_i64_export(&engine, &mut store, &module, "spin").unwrap_err();
1212        assert!(matches!(err, EngineError::OutOfFuel), "got: {err:?}");
1213    }
1214
1215    #[test]
1216    fn epoch_interrupt_yields_typed_error() {
1217        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1218        let module = compile_wat(
1219            &engine,
1220            r#"
1221            (module
1222              (func (export "spin") (result i64)
1223                (loop (br 0))
1224                (i64.const 0)))
1225            "#,
1226        )
1227        .unwrap();
1228        let mut store: Store<()> = Store::new(&engine, ());
1229        store.set_fuel(1_000_000_000).unwrap();
1230        store.set_epoch_deadline(1);
1231        engine.increment_epoch();
1232        engine.increment_epoch();
1233        let err = call_i64_export(&engine, &mut store, &module, "spin").unwrap_err();
1234        assert!(
1235            matches!(err, EngineError::EpochInterrupt | EngineError::OutOfFuel),
1236            "got: {err:?}"
1237        );
1238    }
1239
1240    #[test]
1241    fn malformed_module_bytes_yield_compile_error() {
1242        let engine = build_engine(EngineOpts::baseline()).unwrap();
1243        let err = compile_module(&engine, b"not wasm bytes").unwrap_err();
1244        assert!(matches!(err, EngineError::Compile(_)));
1245    }
1246
1247    #[tokio::test(flavor = "current_thread")]
1248    async fn alloc_pair_chain_builds_list_head_in_order() {
1249        use wasmtime::I31;
1250
1251        // Self-recursive $pair shape matching `CompileContext::new_skeleton`.
1252        // The module exports `pair_new` (the helper alloc_pair_chain re-enters
1253        // for each element) and a `go` entry that asks the test host fn for a
1254        // 3-element chain, then walks it to confirm the host-built structure.
1255        let wat = r#"
1256        (module
1257          (rec
1258            (type $pair (struct (field anyref) (field (ref null $pair)))))
1259          (import "test" "make_chain"
1260            (func $make_chain (result (ref null struct))))
1261          (func $pair_new (export "pair_new")
1262            (param $car anyref) (param $cdr (ref null $pair))
1263            (result (ref null $pair))
1264            (struct.new $pair (local.get $car) (local.get $cdr)))
1265          (func $length (param $head (ref null $pair)) (result i32)
1266            (local $count i32)
1267            (block $exit
1268              (loop $more
1269                (br_if $exit (ref.is_null (local.get $head)))
1270                (local.set $count (i32.add (local.get $count) (i32.const 1)))
1271                (local.set $head
1272                  (struct.get $pair 1 (local.get $head)))
1273                (br $more)))
1274            (local.get $count))
1275          (func (export "go") (result i32)
1276            (local $head (ref null $pair))
1277            (local.set $head
1278              (ref.cast (ref null $pair) (call $make_chain)))
1279            (call $length (local.get $head))))
1280        "#;
1281
1282        let engine = build_engine(EngineOpts::baseline()).unwrap();
1283        let module = compile_wat(&engine, wat).unwrap();
1284        let mut linker: Linker<()> = Linker::new(&engine);
1285        linker
1286            .func_wrap_async("test", "make_chain", |mut caller: Caller<'_, ()>, ()| {
1287                Box::new(async move {
1288                    let items: Vec<Rooted<AnyRef>> = (0..3)
1289                        .map(|i| AnyRef::from_i31(caller.as_context_mut(), I31::wrapping_u32(i)))
1290                        .collect();
1291                    alloc_pair_chain(&mut caller, items).await
1292                })
1293            })
1294            .unwrap();
1295        let mut store: Store<()> = Store::new(&engine, ());
1296        store.set_epoch_deadline(1_000);
1297        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
1298        let go = instance.get_func(&mut store, "go").unwrap();
1299        let mut results = [Val::I32(0)];
1300        go.call_async(&mut store, &[], &mut results).await.unwrap();
1301        assert_eq!(results[0].i32(), Some(3));
1302    }
1303
1304    #[tokio::test(flavor = "current_thread")]
1305    async fn alloc_pair_chain_errors_without_pair_new_export() {
1306        use wasmtime::Func;
1307
1308        // No `pair_new` export — the host fn must surface the missing-export
1309        // contract violation rather than panic or silently succeed.
1310        let wat = r#"
1311        (module
1312          (import "test" "try_chain"
1313            (func $try))
1314          (func (export "go") (call $try)))
1315        "#;
1316        let engine = build_engine(EngineOpts::baseline()).unwrap();
1317        let module = compile_wat(&engine, wat).unwrap();
1318        let mut linker: Linker<()> = Linker::new(&engine);
1319        linker
1320            .func_wrap_async("test", "try_chain", |mut caller: Caller<'_, ()>, ()| {
1321                Box::new(async move {
1322                    let empty: Vec<Rooted<AnyRef>> = Vec::new();
1323                    let result = alloc_pair_chain(&mut caller, empty).await;
1324                    match result {
1325                        Err(e) => {
1326                            let msg = e.to_string();
1327                            assert!(
1328                                msg.contains("pair_new"),
1329                                "expected pair_new-missing error, got: {msg}"
1330                            );
1331                            Ok(())
1332                        }
1333                        Ok(_) => Err(wasmtime::Error::msg(
1334                            "alloc_pair_chain unexpectedly succeeded without pair_new",
1335                        )),
1336                    }
1337                })
1338            })
1339            .unwrap();
1340        let mut store: Store<()> = Store::new(&engine, ());
1341        store.set_epoch_deadline(1_000);
1342        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
1343        let go: Func = instance.get_func(&mut store, "go").unwrap();
1344        let mut results: [Val; 0] = [];
1345        go.call_async(&mut store, &[], &mut results).await.unwrap();
1346    }
1347
1348    #[tokio::test(flavor = "current_thread")]
1349    async fn alloc_commodity_ref_builds_atomic_via_reentry() {
1350        // ADR-0028 E0: the host builds a commodity by re-entering the module's
1351        // exported `commodity_new`, which writes a NULL unit-term (= atomic).
1352        // The `$commodity` shape matches `CompileContext::new_skeleton` (5
1353        // fields, the 5th a `(ref null $unit_term)`). `go` asks the host for a
1354        // 7/2 commodity with UUID hi=1/lo=2, then reads numer, hi, lo, and
1355        // whether the term is null.
1356        let wat = r#"
1357        (module
1358          (type $unit_term (array (mut i64)))
1359          (type $commodity
1360            (struct (field i64) (field i64) (field i64) (field i64)
1361                    (field (ref null $unit_term))))
1362          (import "test" "make_commodity"
1363            (func $make_commodity (result (ref null struct))))
1364          (func $commodity_new (export "commodity_new")
1365            (param $n i64) (param $d i64) (param $hi i64) (param $lo i64)
1366            (result (ref $commodity))
1367            (struct.new $commodity
1368              (local.get $n) (local.get $d) (local.get $hi) (local.get $lo)
1369              (ref.null $unit_term)))
1370          (func (export "go") (result i64 i64 i64 i32)
1371            (local $c (ref $commodity))
1372            (local.set $c
1373              (ref.cast (ref $commodity) (call $make_commodity)))
1374            (struct.get $commodity 0 (local.get $c))
1375            (struct.get $commodity 2 (local.get $c))
1376            (struct.get $commodity 3 (local.get $c))
1377            (ref.is_null (struct.get $commodity 4 (local.get $c)))))
1378        "#;
1379
1380        let engine = build_engine(EngineOpts::baseline()).unwrap();
1381        let module = compile_wat(&engine, wat).unwrap();
1382        let mut linker: Linker<()> = Linker::new(&engine);
1383        linker
1384            .func_wrap_async(
1385                "test",
1386                "make_commodity",
1387                |mut caller: Caller<'_, ()>, ()| {
1388                    Box::new(async move {
1389                        let id = Uuid::from_u128((1u128 << 64) | 2u128);
1390                        Ok(Some(alloc_commodity_ref(&mut caller, 7, 2, id).await?))
1391                    })
1392                },
1393            )
1394            .unwrap();
1395        let mut store: Store<()> = Store::new(&engine, ());
1396        store.set_epoch_deadline(1_000);
1397        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
1398        let go = instance.get_func(&mut store, "go").unwrap();
1399        let mut results = [Val::I64(0), Val::I64(0), Val::I64(0), Val::I32(0)];
1400        go.call_async(&mut store, &[], &mut results).await.unwrap();
1401        assert_eq!(results[0].i64(), Some(7), "numer");
1402        assert_eq!(results[1].i64(), Some(1), "commodity_hi");
1403        assert_eq!(results[2].i64(), Some(2), "commodity_lo");
1404        assert_eq!(results[3].i32(), Some(1), "atomic ⇒ null unit-term");
1405    }
1406
1407    #[tokio::test(flavor = "current_thread")]
1408    async fn alloc_commodity_ref_errors_without_commodity_new_export() {
1409        use wasmtime::Func;
1410
1411        // No `commodity_new` export — the host fn must surface the missing-
1412        // export contract violation rather than panic or silently succeed.
1413        let wat = r#"
1414        (module
1415          (import "test" "try_make"
1416            (func $try))
1417          (func (export "go") (call $try)))
1418        "#;
1419        let engine = build_engine(EngineOpts::baseline()).unwrap();
1420        let module = compile_wat(&engine, wat).unwrap();
1421        let mut linker: Linker<()> = Linker::new(&engine);
1422        linker
1423            .func_wrap_async("test", "try_make", |mut caller: Caller<'_, ()>, ()| {
1424                Box::new(async move {
1425                    let id = Uuid::from_u128(0);
1426                    match alloc_commodity_ref(&mut caller, 1, 1, id).await {
1427                        Err(e) => {
1428                            let msg = e.to_string();
1429                            assert!(
1430                                msg.contains("commodity_new"),
1431                                "expected commodity_new-missing error, got: {msg}"
1432                            );
1433                            Ok(())
1434                        }
1435                        Ok(_) => Err(wasmtime::Error::msg(
1436                            "alloc_commodity_ref unexpectedly succeeded without commodity_new",
1437                        )),
1438                    }
1439                })
1440            })
1441            .unwrap();
1442        let mut store: Store<()> = Store::new(&engine, ());
1443        store.set_epoch_deadline(1_000);
1444        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
1445        let go: Func = instance.get_func(&mut store, "go").unwrap();
1446        let mut results: [Val; 0] = [];
1447        go.call_async(&mut store, &[], &mut results).await.unwrap();
1448    }
1449
1450    #[test]
1451    fn unit_term_algebra_merges_sorts_and_cancels() {
1452        use nomiscript::{Compiler, Reader, SymbolTable};
1453
1454        fn ar(a: Rooted<AnyRef>) -> Val {
1455            Val::AnyRef(Some(a))
1456        }
1457
1458        // Compile a trivial program just to obtain the skeleton, which exports
1459        // the unit-term helpers. Then exercise the sorted-merge directly: it is
1460        // the riskiest hand-written wasm in ADR-0028 E1/E2.
1461        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1462        let mut compiler = Compiler::new();
1463        let mut symbols = SymbolTable::with_builtins();
1464        let program = Reader::parse("0").unwrap();
1465        let (bytes, _) = compiler
1466            .compile_eval_with_type(&program, &mut symbols)
1467            .expect("eval compile");
1468        let module = compile_module(&engine, &bytes).expect("module");
1469        let mut linker: Linker<()> = Linker::new(&engine);
1470        link_nomi_raise_stub(&mut linker, &engine);
1471        let mut store: Store<()> = Store::new(&engine, ());
1472        store.set_fuel(100_000_000).unwrap();
1473        store.set_epoch_deadline(1);
1474        let instance = linker.instantiate(&mut store, &module).unwrap();
1475
1476        let call_ref = |store: &mut Store<()>, name: &str, args: &[Val]| -> Rooted<AnyRef> {
1477            let f = instance.get_func(&mut *store, name).unwrap();
1478            let mut res = [Val::AnyRef(None)];
1479            f.call(&mut *store, args, &mut res).unwrap();
1480            match &res[0] {
1481                Val::AnyRef(Some(a)) => *a,
1482                other => panic!("{name} returned {other:?}"),
1483            }
1484        };
1485        let read_term = |store: &mut Store<()>, t: Rooted<AnyRef>| -> Vec<i64> {
1486            let arr = t.unwrap_array(&mut *store).unwrap();
1487            let len = arr.len(&mut *store).unwrap();
1488            (0..len)
1489                .map(|i| arr.get(&mut *store, i).unwrap().i64().unwrap())
1490                .collect()
1491        };
1492        let singleton = |store: &mut Store<()>, hi: i64, lo: i64| -> Rooted<AnyRef> {
1493            call_ref(store, "unit_singleton", &[Val::I64(hi), Val::I64(lo)])
1494        };
1495
1496        let usd = singleton(&mut store, 10, 20);
1497        let eur = singleton(&mut store, 30, 40);
1498        assert_eq!(read_term(&mut store, usd), vec![10, 20, 1]);
1499        assert_eq!(read_term(&mut store, eur), vec![30, 40, 1]);
1500
1501        // Disjoint merge is sorted by (hi,lo), order-independent.
1502        let usd_eur = call_ref(&mut store, "unit_mul", &[ar(usd), ar(eur)]);
1503        assert_eq!(read_term(&mut store, usd_eur), vec![10, 20, 1, 30, 40, 1]);
1504        let eur_usd = call_ref(&mut store, "unit_mul", &[ar(eur), ar(usd)]);
1505        assert_eq!(read_term(&mut store, eur_usd), vec![10, 20, 1, 30, 40, 1]);
1506
1507        // Matching key sums exponents.
1508        let usd2 = call_ref(&mut store, "unit_mul", &[ar(usd), ar(usd)]);
1509        assert_eq!(read_term(&mut store, usd2), vec![10, 20, 2]);
1510
1511        // Cancellation drops the zero-exponent entry → empty (dimensionless).
1512        let canceled = call_ref(&mut store, "unit_div", &[ar(usd), ar(usd)]);
1513        assert_eq!(read_term(&mut store, canceled), Vec::<i64>::new());
1514
1515        // negate flips exponents.
1516        let neg_usd = call_ref(&mut store, "unit_negate", &[ar(usd)]);
1517        assert_eq!(read_term(&mut store, neg_usd), vec![10, 20, -1]);
1518
1519        let eq = |store: &mut Store<()>, a: Rooted<AnyRef>, b: Rooted<AnyRef>| -> i32 {
1520            let f = instance.get_func(&mut *store, "unit_eq").unwrap();
1521            let mut res = [Val::I32(0)];
1522            f.call(&mut *store, &[ar(a), ar(b)], &mut res).unwrap();
1523            res[0].i32().unwrap()
1524        };
1525        assert_eq!(eq(&mut store, usd, usd), 1);
1526        assert_eq!(eq(&mut store, usd, eur), 0);
1527        // Same multiset, built two ways, compares equal.
1528        assert_eq!(eq(&mut store, usd_eur, eur_usd), 1);
1529    }
1530
1531    #[tokio::test(flavor = "current_thread")]
1532    async fn compound_money_arithmetic_end_to_end() {
1533        use nomiscript::{Compiler, HostFnSpec, Reader, SymbolTable, WasmType};
1534
1535        // Two distinct currencies, each produced atomic at 3/1 by a host fn.
1536        const USD: u128 = 0x1111_1111_1111_1111_2222_2222_2222_2222;
1537        const EUR: u128 = 0x3333_3333_3333_3333_4444_4444_4444_4444;
1538
1539        async fn run(src: &str) -> Result<EvalValue, String> {
1540            let specs = vec![
1541                HostFnSpec::new("usd", "test", "usd").returns(WasmType::Commodity),
1542                HostFnSpec::new("eur", "test", "eur").returns(WasmType::Commodity),
1543                HostFnSpec::new("sink", "test", "sink")
1544                    .with_params(vec![WasmType::Commodity])
1545                    .returns(WasmType::I32),
1546            ];
1547            let program = Reader::parse(src).unwrap();
1548            let mut compiler = Compiler::with_host_fns(specs.clone());
1549            let mut symbols = SymbolTable::with_builtins();
1550            symbols.register_host_fns(&specs);
1551            let (bytes, result_ty) = compiler
1552                .compile_eval_with_type(&program, &mut symbols)
1553                .map_err(|e| e.to_string())?;
1554            let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1555            let module = compile_module(&engine, &bytes).map_err(|e| format!("{e:?}"))?;
1556            let mut linker: Linker<()> = Linker::new(&engine);
1557            link_nomi_raise_stub(&mut linker, &engine);
1558            linker
1559                .func_wrap_async("test", "usd", |mut caller: Caller<'_, ()>, ()| {
1560                    Box::new(async move {
1561                        Ok(Some(
1562                            alloc_commodity_ref(&mut caller, 3, 1, Uuid::from_u128(USD)).await?,
1563                        ))
1564                    })
1565                })
1566                .unwrap();
1567            linker
1568                .func_wrap_async("test", "eur", |mut caller: Caller<'_, ()>, ()| {
1569                    Box::new(async move {
1570                        Ok(Some(
1571                            alloc_commodity_ref(&mut caller, 3, 1, Uuid::from_u128(EUR)).await?,
1572                        ))
1573                    })
1574                })
1575                .unwrap();
1576            // The sink only reaches its body for ATOMIC args — a compound arg is
1577            // rejected by the `commodity_assert_atomic` guard before this runs.
1578            linker
1579                .func_wrap_async(
1580                    "test",
1581                    "sink",
1582                    |mut caller: Caller<'_, ()>, (arg,): (Option<Rooted<StructRef>>,)| {
1583                        Box::new(async move {
1584                            read_commodity_arg(&mut caller, arg)?;
1585                            Ok(0i32)
1586                        })
1587                    },
1588                )
1589                .unwrap();
1590            let mut store: Store<()> = Store::new(&engine, ());
1591            store.set_fuel(1_000_000_000).unwrap();
1592            store.set_epoch_deadline(1);
1593            let instance = linker
1594                .instantiate_async(&mut store, &module)
1595                .await
1596                .map_err(|e| format!("{e:?}"))?;
1597            let func = instance.get_func(&mut store, "nomi-eval").unwrap();
1598            let mut results = [Val::AnyRef(None)];
1599            func.call_async(&mut store, &[], &mut results)
1600                .await
1601                .map_err(|e| e.to_string())?;
1602            let any = match &results[0] {
1603                Val::AnyRef(a) => *a,
1604                _ => return Err("nomi-eval returned non-anyref".to_string()),
1605            };
1606            decode_eval_result(&mut store, any, result_ty).map_err(|e| e.to_string())
1607        }
1608
1609        // money ÷ money, same currency → dimensionless → decodes as a Number.
1610        assert_eq!(
1611            run("(/ (usd) (usd))").await,
1612            Ok(EvalValue::Ratio { numer: 1, denom: 1 })
1613        );
1614        // money + money, same currency → atomic money (the null-term fast path).
1615        match run("(+ (usd) (usd))").await {
1616            Ok(EvalValue::Commodity { numer, denom, .. }) => assert_eq!((numer, denom), (6, 1)),
1617            other => panic!("expected atomic commodity 6/1, got {other:?}"),
1618        }
1619        // money + money, different currency → COMMODITY-MISMATCH throw.
1620        assert!(run("(+ (usd) (eur))").await.is_err());
1621        // money × money → compound, no host wire form yet → decode error.
1622        assert!(
1623            run("(* (usd) (usd))")
1624                .await
1625                .unwrap_err()
1626                .contains("compound")
1627        );
1628        // an ATOMIC money passes the host-border guard.
1629        assert_eq!(run("(sink (usd))").await, Ok(EvalValue::I32(0)));
1630        // a COMPOUND money is rejected at the host border by the guard.
1631        assert!(run("(sink (* (usd) (usd)))").await.is_err());
1632        // a COMPOUND money riding a $pair cell is rejected by the pair-car
1633        // renderer too — it must NOT slip through as id-zero atomic money
1634        // (the pair-decode border is field-4-aware, same as the top-level one).
1635        assert!(
1636            run("(list (* (usd) (usd)))")
1637                .await
1638                .unwrap_err()
1639                .contains("compound")
1640        );
1641        // an atomic money in a list cell still renders (sanity: the field-4
1642        // gate doesn't reject the null-term atomic case).
1643        assert!(run("(list (usd))").await.is_ok());
1644        // dimensionless × atomic → a `[(usd,1)]` singleton term that
1645        // `commodity_new_with_term` canonicalizes back to ATOMIC usd: it decodes
1646        // as an atomic commodity (3/1) and passes the host-border atomic guard.
1647        match run("(* (/ (usd) (usd)) (usd))").await {
1648            Ok(EvalValue::Commodity { numer, denom, .. }) => assert_eq!((numer, denom), (3, 1)),
1649            other => panic!("expected atomic commodity 3/1, got {other:?}"),
1650        }
1651        assert_eq!(
1652            run("(sink (* (/ (usd) (usd)) (usd)))").await,
1653            Ok(EvalValue::I32(0))
1654        );
1655    }
1656
1657    /// The eval-mode `CompileContext` declares `nomi.__nomi_raise` for
1658    /// `(error 'code "msg")` lowering even when no `(error)` form is
1659    /// present in the program. The host side lives in the rpc crate;
1660    /// for the scripting-crate runtime tests we link a never-called
1661    /// stub so `instantiate()` resolves the import.
1662    fn link_nomi_raise_stub(linker: &mut Linker<()>, engine: &wasmtime::Engine) {
1663        linker
1664            .func_new(
1665                "nomi",
1666                "__nomi_raise",
1667                wasmtime::FuncType::new(
1668                    engine,
1669                    [
1670                        wasmtime::ValType::Ref(wasmtime::RefType::ARRAYREF),
1671                        wasmtime::ValType::Ref(wasmtime::RefType::ARRAYREF),
1672                    ],
1673                    [],
1674                ),
1675                |_, _, _| {
1676                    Err(wasmtime::Error::msg(
1677                        "__nomi_raise stub: not linked in this test",
1678                    ))
1679                },
1680            )
1681            .unwrap();
1682        link_log_stub(linker, engine);
1683        link_nomi_catch_each_stub(linker, engine);
1684    }
1685
1686    /// `env.log` `(i32 level, i32 ptr, i32 len) -> ()` stub. Eval-mode modules
1687    /// import `env.log` (PRINT / DISPLAY / NEWLINE / DEBUG lower to it); a test
1688    /// linker that instantiates an eval module must define it or instantiation
1689    /// fails with "unknown import". Production wires it via `scripting::host`
1690    /// (script mode) / `rpc::natives::env_io` (eval mode); this no-op stub
1691    /// suffices for tests that don't assert on logged output.
1692    fn link_log_stub(linker: &mut Linker<()>, engine: &wasmtime::Engine) {
1693        linker
1694            .func_new(
1695                "env",
1696                "log",
1697                wasmtime::FuncType::new(
1698                    engine,
1699                    [
1700                        wasmtime::ValType::I32,
1701                        wasmtime::ValType::I32,
1702                        wasmtime::ValType::I32,
1703                    ],
1704                    [],
1705                ),
1706                |_, _, _| Ok(()),
1707            )
1708            .unwrap();
1709    }
1710
1711    /// Companion to `link_nomi_raise_stub`. The eval compile context now
1712    /// declares `__nomi_catch_each` up-front (so its import index is
1713    /// stable before any user host fn is wired — matches `__nomi_raise`'s
1714    /// shape), so even programs that don't use `(catch-each ...)` still
1715    /// need the import resolvable at instantiation time. The stub traps
1716    /// on call so a regression that accidentally invokes catch-each in
1717    /// these unit tests surfaces loudly rather than silently no-oping.
1718    fn link_nomi_catch_each_stub(linker: &mut Linker<()>, engine: &wasmtime::Engine) {
1719        let abstract_struct =
1720            wasmtime::ValType::Ref(wasmtime::RefType::new(true, wasmtime::HeapType::Struct));
1721        let funcref = wasmtime::ValType::Ref(wasmtime::RefType::FUNCREF);
1722        let anyref = wasmtime::ValType::Ref(wasmtime::RefType::ANYREF);
1723        linker
1724            .func_new(
1725                "nomi",
1726                "__nomi_catch_each",
1727                wasmtime::FuncType::new(
1728                    engine,
1729                    [funcref, anyref, abstract_struct.clone()],
1730                    [abstract_struct],
1731                ),
1732                |_, _, _| {
1733                    Err(wasmtime::Error::msg(
1734                        "__nomi_catch_each stub: not linked in this test",
1735                    ))
1736                },
1737            )
1738            .unwrap();
1739    }
1740
1741    /// End-to-end: nomiscript Compiler emits eval-mode bytecode that
1742    /// returns the form's final value via nomi-eval's `(ref null any)`
1743    /// return slot, the runtime instantiates it (no capture host fns
1744    /// linked — they retired in A6.c), and `decode_eval_result` walks
1745    /// the anyref into the structured `EvalValue` the rest of the host
1746    /// renders from.
1747    fn run_nomiscript_eval(program: &nomiscript::Program) -> Option<EvalValue> {
1748        use nomiscript::{Compiler, SymbolTable};
1749        let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1750        let mut compiler = Compiler::new();
1751        let mut symbols = SymbolTable::with_builtins();
1752        let (bytes, result_ty) = compiler
1753            .compile_eval_with_type(program, &mut symbols)
1754            .expect("eval compile");
1755        let module = compile_module(&engine, &bytes).expect("module");
1756        let mut linker: Linker<()> = Linker::new(&engine);
1757        link_nomi_raise_stub(&mut linker, &engine);
1758        let mut store: Store<()> = Store::new(&engine, ());
1759        store.set_fuel(10_000_000).unwrap();
1760        store.set_epoch_deadline(1);
1761        let instance = linker.instantiate(&mut store, &module).unwrap();
1762        let func = instance.get_func(&mut store, "nomi-eval").unwrap();
1763        let mut results = [Val::AnyRef(None)];
1764        func.call(&mut store, &[], &mut results).unwrap();
1765        let any = match &results[0] {
1766            Val::AnyRef(a) => *a,
1767            _ => panic!("nomi-eval returned non-anyref"),
1768        };
1769        Some(decode_eval_result(&mut store, any, result_ty).expect("decode"))
1770    }
1771
1772    #[test]
1773    fn nomiscript_eval_captures_integer_literal() {
1774        use nomiscript::{Expr, Fraction, Program};
1775        // ADR-0028: an integer literal is an Index (I32), decoding as `I32`,
1776        // not the dimensionless `Ratio` it conflated with before the flip.
1777        let program = Program::new(vec![Expr::Number(Fraction::from_integer(7))]);
1778        assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::I32(7)));
1779    }
1780
1781    #[test]
1782    fn nomiscript_eval_captures_arithmetic_result() {
1783        use nomiscript::{Expr, Fraction, Program};
1784        // All-integer (Index) arithmetic stays in the Index stratum: `(+ 1 2)`
1785        // decodes as `I32(3)`.
1786        let program = Program::new(vec![Expr::List(vec![
1787            Expr::Symbol("+".into()),
1788            Expr::Number(Fraction::from_integer(1)),
1789            Expr::Number(Fraction::from_integer(2)),
1790        ])]);
1791        assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::I32(3)));
1792    }
1793
1794    #[test]
1795    fn nomiscript_eval_captures_fractional_result() {
1796        use nomiscript::{Expr, Fraction, Program};
1797        // A Scalar operand keeps rational division: `(/ 1/2 2) → 1/4`, decoding
1798        // as `Ratio`. (All-integer `(/ 1 4)` would be Index `0`.)
1799        let program = Program::new(vec![Expr::List(vec![
1800            Expr::Symbol("/".into()),
1801            Expr::Number(Fraction::new(1, 2)),
1802            Expr::Number(Fraction::from_integer(2)),
1803        ])]);
1804        assert_eq!(
1805            run_nomiscript_eval(&program),
1806            Some(EvalValue::Ratio { numer: 1, denom: 4 })
1807        );
1808    }
1809
1810    #[test]
1811    fn nomiscript_eval_captures_nil_for_empty_program() {
1812        let program = nomiscript::Program::default();
1813        assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::Nil));
1814    }
1815
1816    #[test]
1817    fn nomiscript_eval_decodes_bool_as_bool() {
1818        use nomiscript::{Expr, Program};
1819        let program = Program::new(vec![Expr::Bool(true)]);
1820        // `#t` carries `WasmType::Bool` (i31-boxed); the decoder surfaces a
1821        // truthy bool as `Bool(true)` (a falsy one would be `Nil`), not the
1822        // raw integer the old i32-conflated path produced.
1823        assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::Bool(true)));
1824    }
1825
1826    /// Drift-detector: compile-eval each script source, run it, and
1827    /// verify the static `result_ty` hint reported by
1828    /// `compile_eval_with_type` matches the decoded `EvalValue`'s
1829    /// variant. Catches eval-vs-codegen drift across the compiler —
1830    /// the exact bug class that produced the tag-sync test regressions.
1831    /// Add a row whenever a new WasmType or Expr-shape is supported.
1832    #[test]
1833    fn nomiscript_eval_type_hint_matches_value_variant() {
1834        use nomiscript::{Compiler, Program, Reader, SymbolTable, WasmType};
1835        let cases: &[(&str, Option<WasmType>)] = &[
1836            // ADR-0028: integer literals + all-integer arithmetic are Index
1837            // (I32); a fractional literal is Scalar (Ratio).
1838            ("42", Some(WasmType::I32)),
1839            ("(+ 1 2)", Some(WasmType::I32)),
1840            ("(/ 1 4)", Some(WasmType::I32)),
1841            ("(/ 1/2 2)", Some(WasmType::Ratio)),
1842            ("(= 1 1)", Some(WasmType::Bool)),
1843            ("(< 1 2)", Some(WasmType::Bool)),
1844            ("#t", Some(WasmType::Bool)),
1845            ("\"hello\"", Some(WasmType::StringRef)),
1846            ("(let ((x 1)) (+ x 1))", Some(WasmType::I32)),
1847            ("(let ((x 1)) \"tail\")", Some(WasmType::StringRef)),
1848            ("(if (= 1 1) 2 3)", Some(WasmType::I32)),
1849        ];
1850        for (src, expected_ty) in cases {
1851            let program: Program = Reader::parse(src).expect("parse");
1852            let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
1853            let mut compiler = Compiler::new();
1854            let mut symbols = SymbolTable::with_builtins();
1855            let (bytes, result_ty) = compiler
1856                .compile_eval_with_type(&program, &mut symbols)
1857                .unwrap_or_else(|e| panic!("compile {src:?}: {e}"));
1858            assert_eq!(
1859                &result_ty, expected_ty,
1860                "compile_eval_with_type reported wrong static type for {src:?}",
1861            );
1862            let module = compile_module(&engine, &bytes).expect("module");
1863            let mut linker: Linker<()> = Linker::new(&engine);
1864            link_nomi_raise_stub(&mut linker, &engine);
1865            let mut store: Store<()> = Store::new(&engine, ());
1866            store.set_fuel(10_000_000).unwrap();
1867            store.set_epoch_deadline(1);
1868            let instance = linker.instantiate(&mut store, &module).unwrap();
1869            let func = instance.get_func(&mut store, "nomi-eval").unwrap();
1870            let mut results = [Val::AnyRef(None)];
1871            func.call(&mut store, &[], &mut results)
1872                .unwrap_or_else(|e| panic!("run {src:?}: {e}"));
1873            let any = match &results[0] {
1874                Val::AnyRef(a) => *a,
1875                _ => panic!("nomi-eval returned non-anyref for {src:?}"),
1876            };
1877            let decoded = decode_eval_result(&mut store, any, result_ty)
1878                .unwrap_or_else(|e| panic!("decode {src:?}: {e}"));
1879            // The mapping below is the canonical EvalValue ↔ WasmType
1880            // contract. Any drift fails here.
1881            let ok = matches!(
1882                (&result_ty, &decoded),
1883                (None, EvalValue::Nil)
1884                    | (Some(WasmType::I32), EvalValue::I32(_))
1885                    // A Bool decodes to Bool(true) when truthy or Nil when falsy.
1886                    | (Some(WasmType::Bool), EvalValue::Bool(_) | EvalValue::Nil)
1887                    | (Some(WasmType::Ratio), EvalValue::Ratio { .. })
1888                    | (Some(WasmType::Commodity), EvalValue::Commodity { .. })
1889                    | (
1890                        Some(WasmType::StringRef),
1891                        EvalValue::String(_) | EvalValue::Bytes(_)
1892                    ),
1893            );
1894            assert!(
1895                ok,
1896                "type/value drift for {src:?}: hint={result_ty:?}, decoded={decoded:?}",
1897            );
1898        }
1899    }
1900
1901    #[tokio::test(flavor = "current_thread")]
1902    async fn render_entity_emits_named_field_plist() {
1903        // Build a $commodity-shaped struct (3 string fields, matching the
1904        // Commodity layout's id/symbol/name slots) via WAT, then decode it with
1905        // `render_entity` — the same call the host eval path makes for a returned
1906        // entity. Proves the spec-driven decoder reads each slot by name.
1907        let wat = r#"
1908        (module
1909          (type $i8 (array (mut i8)))
1910          (type $commodity (struct
1911            (field (ref null $i8))
1912            (field (ref null $i8))
1913            (field (ref null $i8))))
1914          (data $id "uuid-123")
1915          (data $sym "USD")
1916          (data $name "US Dollar")
1917          (func (export "go") (result (ref null struct))
1918            (struct.new $commodity
1919              (array.new_data $i8 $id  (i32.const 0) (i32.const 8))
1920              (array.new_data $i8 $sym (i32.const 0) (i32.const 3))
1921              (array.new_data $i8 $name (i32.const 0) (i32.const 9)))))
1922        "#;
1923        let engine = build_engine(EngineOpts::baseline()).unwrap();
1924        let module = compile_wat(&engine, wat).unwrap();
1925        let linker: Linker<()> = Linker::new(&engine);
1926        let mut store: Store<()> = Store::new(&engine, ());
1927        store.set_epoch_deadline(1_000);
1928        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
1929        let go = instance.get_func(&mut store, "go").unwrap();
1930        let mut results = [Val::AnyRef(None)];
1931        go.call_async(&mut store, &[], &mut results).await.unwrap();
1932        let entity = match results[0] {
1933            Val::AnyRef(Some(a)) => a.unwrap_struct(&mut store).unwrap(),
1934            other => panic!("go did not return a struct: {other:?}"),
1935        };
1936        let rendered =
1937            render_entity(&mut store, entity, nomiscript::EntityKind::Commodity).unwrap();
1938        assert_eq!(
1939            rendered,
1940            "(:commodity :id \"uuid-123\" :symbol \"USD\" :name \"US Dollar\")"
1941        );
1942    }
1943
1944    #[tokio::test(flavor = "current_thread")]
1945    async fn read_entity_string_field_reads_named_slot() {
1946        // Same Commodity-shaped struct; prove the field reader resolves a named
1947        // slot from the layout (id = slot 0, name = slot 2) — the call
1948        // `draft-split` makes to turn an entity ref into a stable uuid string.
1949        let wat = r#"
1950        (module
1951          (type $i8 (array (mut i8)))
1952          (type $commodity (struct
1953            (field (ref null $i8))
1954            (field (ref null $i8))
1955            (field (ref null $i8))))
1956          (data $id "uuid-123")
1957          (data $sym "USD")
1958          (data $name "US Dollar")
1959          (func (export "go") (result (ref null struct))
1960            (struct.new $commodity
1961              (array.new_data $i8 $id  (i32.const 0) (i32.const 8))
1962              (array.new_data $i8 $sym (i32.const 0) (i32.const 3))
1963              (array.new_data $i8 $name (i32.const 0) (i32.const 9)))))
1964        "#;
1965        let engine = build_engine(EngineOpts::baseline()).unwrap();
1966        let module = compile_wat(&engine, wat).unwrap();
1967        let linker: Linker<()> = Linker::new(&engine);
1968        let mut store: Store<()> = Store::new(&engine, ());
1969        store.set_epoch_deadline(1_000);
1970        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
1971        let go = instance.get_func(&mut store, "go").unwrap();
1972        let mut results = [Val::AnyRef(None)];
1973        go.call_async(&mut store, &[], &mut results).await.unwrap();
1974        let entity = match results[0] {
1975            Val::AnyRef(Some(a)) => a.unwrap_struct(&mut store).unwrap(),
1976            other => panic!("go did not return a struct: {other:?}"),
1977        };
1978
1979        let id = read_entity_string_field_ctx(
1980            &mut store,
1981            entity,
1982            nomiscript::EntityKind::Commodity,
1983            "id",
1984        )
1985        .unwrap();
1986        assert_eq!(id, "uuid-123");
1987
1988        let name = read_entity_string_field_ctx(
1989            &mut store,
1990            entity,
1991            nomiscript::EntityKind::Commodity,
1992            "name",
1993        )
1994        .unwrap();
1995        assert_eq!(name, "US Dollar");
1996
1997        // A field that isn't a String slot in the layout is rejected.
1998        let bad = read_entity_string_field_ctx(
1999            &mut store,
2000            entity,
2001            nomiscript::EntityKind::Commodity,
2002            "nonexistent",
2003        );
2004        assert!(bad.is_err());
2005    }
2006}