Skip to main content

dynamic_cli/plugin/
wasm.rs

1//! WASM plugin loader for `dynamic-cli` (Option C — DD-021)
2//!
3//! Provides [`WasmPlugin`], a [`Plugin`] implementation backed by a sandboxed
4//! WebAssembly module loaded and executed via `wasmtime`. Only available
5//! when the `wasm-plugins` feature is enabled.
6//!
7//! # Why WASM plugins
8//!
9//! Static plugins ([`SystemPlugin`][crate::plugin::SystemPlugin] and Option A
10//! in general) must be compiled into the host binary. WASM plugins trade
11//! that compile-time coupling for a safe, cross-platform sandbox: a `.wasm`
12//! module can be distributed independently of the host application and
13//! loaded at runtime, with no `unsafe` code on the host side.
14//!
15//! # ABI contract — mandatory exports
16//!
17//! Every WASM module loaded as a [`WasmPlugin`] **must** export:
18//!
19//! | Export | Signature | Purpose |
20//! |--------|-----------|---------|
21//! | `memory` | (standard linear memory) | Shared buffer for argument/result transfer |
22//! | `dcli_alloc` | `(size: i32) -> i32` | Host asks the guest to reserve `size` bytes; returns the pointer |
23//! | `dcli_dealloc` | `(ptr: i32, size: i32)` | Host asks the guest to free a buffer it previously allocated |
24//! | *(business function)* | `(ptr: i32, len: i32) -> i32` | Reads serialized args at `ptr`/`len`; returns `0` on success, non-zero on error |
25//!
26//! The business function's exported name is chosen freely by the plugin
27//! author and mapped to an `implementation` name via
28//! [`WasmPlugin::with_function_map`].
29//!
30//! # ABI contract — optional exports
31//!
32//! | Export | Signature | Purpose |
33//! |--------|-----------|---------|
34//! | `dcli_last_error_message` | `() -> (ptr: i32, len: i32)` | Detailed error message when the business function returns non-zero |
35//!
36//! When absent, errors surface with the raw code only
37//! ([`WasmError::guest_error_without_message`]).
38//!
39//! # Serialization
40//!
41//! Handler arguments (`HashMap<String, String>`) are serialized to a byte
42//! buffer before crossing the host/guest boundary. YAML is the default,
43//! consistent with the framework's config-first principle (DD-002); JSON is
44//! available via [`WasmPlugin::with_format`].
45//!
46//! # Known limitation — no `ExecutionContext` access
47//!
48//! WASM handlers do **not** receive the host's [`ExecutionContext`]. Trait
49//! objects cannot cross the WASM FFI boundary, and exposing arbitrary host
50//! state to a sandboxed guest would defeat the purpose of the sandbox. WASM
51//! plugins in this version only exchange serialized arguments and a result
52//! code/message.
53//!
54//! Future work may introduce a restricted set of host functions (e.g.
55//! `host_log`, `host_get_state`) or WASI integration for guests that need
56//! controlled access to host capabilities — see DD-021 for the open
57//! discussion. This version intentionally ships without them.
58//!
59//! Full reference: `WASM_PLUGIN_INTERFACE.md`.
60//!
61//! # Example
62//!
63//! ```no_run
64//! use dynamic_cli::plugin::wasm::{WasmPlugin, WasmSerializationFormat};
65//! use std::path::Path;
66//!
67//! # fn main() -> dynamic_cli::Result<()> {
68//! let plugin = WasmPlugin::load(Path::new("plugins/greet.wasm"))?
69//!     .with_function_map("greet_hello", "say_hello")
70//!     .with_format(WasmSerializationFormat::Yaml)
71//!     .with_metadata("greet", "1.0.0", "Greeting commands");
72//! # Ok(())
73//! # }
74//! ```
75
76use crate::context::ExecutionContext;
77use crate::error::WasmError;
78use crate::executor::CommandHandler;
79use crate::parser::ParsedArgs;
80use crate::plugin::Plugin;
81use crate::Result;
82use std::collections::HashMap;
83use std::path::Path;
84use wasmtime::{Engine, Instance, Module, Store};
85
86// ============================================================================
87// WasmSerializationFormat
88// ============================================================================
89
90/// Serialization format used to exchange handler arguments across the
91/// host/guest boundary.
92///
93/// YAML is the default, consistent with the framework's config-first
94/// principle (DD-002). Guests that prefer JSON can request it via
95/// [`WasmPlugin::with_format`].
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
97pub enum WasmSerializationFormat {
98    /// YAML-encoded arguments (default)
99    #[default]
100    Yaml,
101    /// JSON-encoded arguments
102    Json,
103}
104
105impl WasmSerializationFormat {
106    /// Serialize a handler argument map into bytes using this format.
107    fn serialize(self, args: &HashMap<String, String>) -> Result<Vec<u8>> {
108        let bytes = match self {
109            Self::Yaml => serde_yaml::to_string(args)
110                .map_err(|e| WasmError::SerializationFailed(e.to_string()))?
111                .into_bytes(),
112            Self::Json => serde_json::to_vec(args)
113                .map_err(|e| WasmError::SerializationFailed(e.to_string()))?,
114        };
115        Ok(bytes)
116    }
117}
118
119// ============================================================================
120// Mandatory export names
121// ============================================================================
122
123const EXPORT_MEMORY: &str = "memory";
124const EXPORT_ALLOC: &str = "dcli_alloc";
125const EXPORT_DEALLOC: &str = "dcli_dealloc";
126const EXPORT_LAST_ERROR: &str = "dcli_last_error_message";
127
128// ============================================================================
129// WasmPlugin
130// ============================================================================
131
132/// A [`Plugin`] backed by a sandboxed WASM module.
133///
134/// Load a `.wasm` (or `.wat`, useful for testing) module with [`WasmPlugin::load`],
135/// map its business functions to `implementation` names with
136/// [`with_function_map`][Self::with_function_map], then register it via
137/// [`CliBuilder::register_plugin`][crate::CliBuilder::register_plugin] or the
138/// convenience [`CliBuilder::register_wasm_plugin`][crate::CliBuilder::register_wasm_plugin].
139///
140/// See the [module-level documentation](self) for the full ABI contract.
141pub struct WasmPlugin {
142    name: String,
143    version: String,
144    description: String,
145    engine: Engine,
146    module: Module,
147    /// `implementation_name -> wasm_exported_function_name`
148    function_map: HashMap<String, String>,
149    format: WasmSerializationFormat,
150}
151
152impl WasmPlugin {
153    /// Load a WASM module from disk and validate its mandatory exports.
154    ///
155    /// Accepts both binary `.wasm` and text `.wat` modules (the latter is
156    /// primarily useful for tests and minimal fixtures).
157    ///
158    /// # Errors
159    ///
160    /// Returns [`WasmError::LoadFailed`] if the file cannot be read or fails
161    /// to compile/validate. Returns [`WasmError::FunctionNotFound`] if
162    /// `memory`, `dcli_alloc`, or `dcli_dealloc` is missing — these three
163    /// are mandatory regardless of which business functions are mapped
164    /// later.
165    ///
166    /// # Example
167    ///
168    /// ```no_run
169    /// use dynamic_cli::plugin::wasm::WasmPlugin;
170    /// use std::path::Path;
171    ///
172    /// # fn main() -> dynamic_cli::Result<()> {
173    /// let plugin = WasmPlugin::load(Path::new("plugins/greet.wasm"))?;
174    /// # Ok(())
175    /// # }
176    /// ```
177    pub fn load(path: &Path) -> Result<Self> {
178        let bytes = std::fs::read(path).map_err(|e| WasmError::LoadFailed {
179            path: path.to_path_buf(),
180            source: anyhow::Error::new(e),
181            suggestion: Some("Verify the path is correct and the file is readable.".to_string()),
182        })?;
183
184        Self::from_bytes(&bytes, path)
185    }
186
187    /// Load a WASM module from raw bytes (binary `.wasm` or text `.wat`).
188    ///
189    /// Used internally by [`load`][Self::load]. Exposed for tests and for
190    /// embedding scenarios where the module bytes are not stored on disk
191    /// (e.g. fetched over the network).
192    ///
193    /// Validates the same mandatory exports as [`load`][Self::load].
194    pub fn from_bytes(bytes: &[u8], origin: &Path) -> Result<Self> {
195        let engine = Engine::default();
196
197        let module = Module::new(&engine, bytes).map_err(|e| WasmError::LoadFailed {
198            path: origin.to_path_buf(),
199            source: e.into(),
200            suggestion: Some("Verify the file is a valid WASM binary or WAT module.".to_string()),
201        })?;
202
203        let module_label = origin.display().to_string();
204        Self::validate_mandatory_exports(&module, &module_label)?;
205
206        let default_name = origin
207            .file_stem()
208            .and_then(|s| s.to_str())
209            .unwrap_or("wasm-plugin")
210            .to_string();
211
212        Ok(Self {
213            name: default_name,
214            version: "0.0.0".to_string(),
215            description: "WASM plugin (no metadata provided)".to_string(),
216            engine,
217            module,
218            function_map: HashMap::new(),
219            format: WasmSerializationFormat::default(),
220        })
221    }
222
223    /// Verify that `memory`, `dcli_alloc`, and `dcli_dealloc` are exported.
224    ///
225    /// These three exports are mandatory for every WASM plugin regardless
226    /// of which business functions are mapped — without them the host has
227    /// no safe way to exchange data with the guest or to free guest memory
228    /// after a call.
229    fn validate_mandatory_exports(module: &Module, module_label: &str) -> Result<()> {
230        let exported_names: Vec<&str> = module.exports().map(|e| e.name()).collect();
231
232        for required in [EXPORT_MEMORY, EXPORT_ALLOC, EXPORT_DEALLOC] {
233            if !exported_names.contains(&required) {
234                return Err(WasmError::missing_mandatory_export(required, module_label).into());
235            }
236        }
237        Ok(())
238    }
239
240    /// Map an `implementation` name to a WASM-exported business function.
241    ///
242    /// The exported function name is chosen freely by the plugin author —
243    /// `dynamic-cli` imposes no naming convention beyond the four reserved
244    /// names (`memory`, `dcli_alloc`, `dcli_dealloc`, `dcli_last_error_message`).
245    ///
246    /// Existence of `wasm_fn_name` in the module is verified at handler
247    /// construction time, in [`Plugin::handlers`].
248    ///
249    /// # Example
250    ///
251    /// ```no_run
252    /// use dynamic_cli::plugin::wasm::WasmPlugin;
253    /// use std::path::Path;
254    ///
255    /// # fn main() -> dynamic_cli::Result<()> {
256    /// let plugin = WasmPlugin::load(Path::new("plugins/greet.wasm"))?
257    ///     .with_function_map("greet_hello", "say_hello");
258    /// # Ok(())
259    /// # }
260    /// ```
261    pub fn with_function_map(mut self, impl_name: &str, wasm_fn_name: &str) -> Self {
262        self.function_map
263            .insert(impl_name.to_string(), wasm_fn_name.to_string());
264        self
265    }
266
267    /// Set the serialization format used for argument exchange.
268    ///
269    /// Defaults to [`WasmSerializationFormat::Yaml`].
270    pub fn with_format(mut self, format: WasmSerializationFormat) -> Self {
271        self.format = format;
272        self
273    }
274
275    /// Set plugin metadata (name, version, description).
276    ///
277    /// When not called, defaults are derived from the module's file name
278    /// (`name`), `"0.0.0"` (`version`), and a generic description.
279    pub fn with_metadata(mut self, name: &str, version: &str, description: &str) -> Self {
280        self.name = name.to_string();
281        self.version = version.to_string();
282        self.description = description.to_string();
283        self
284    }
285}
286
287impl Plugin for WasmPlugin {
288    fn name(&self) -> &str {
289        &self.name
290    }
291
292    fn version(&self) -> &str {
293        &self.version
294    }
295
296    fn description(&self) -> &str {
297        &self.description
298    }
299
300    fn handlers(&self) -> Vec<(String, Box<dyn CommandHandler>)> {
301        self.function_map
302            .iter()
303            .map(|(impl_name, wasm_fn_name)| {
304                let handler: Box<dyn CommandHandler> = Box::new(WasmHandler {
305                    engine: self.engine.clone(),
306                    module: self.module.clone(),
307                    module_label: self.name.clone(),
308                    wasm_fn_name: wasm_fn_name.clone(),
309                    format: self.format,
310                });
311                (impl_name.clone(), handler)
312            })
313            .collect()
314    }
315}
316
317// ============================================================================
318// WasmHandler (private)
319// ============================================================================
320
321/// [`CommandHandler`] that invokes a single exported WASM business function.
322///
323/// One instance is created per mapped `implementation` name in
324/// [`WasmPlugin::handlers`]. Each call to [`execute`][CommandHandler::execute]
325/// creates a fresh `Store` and `Instance` — WASM instantiation is cheap and
326/// this keeps each invocation isolated, with no state leaking between calls.
327struct WasmHandler {
328    engine: Engine,
329    module: Module,
330    /// Plugin name, used only to identify the module in error messages
331    module_label: String,
332    wasm_fn_name: String,
333    format: WasmSerializationFormat,
334}
335
336impl WasmHandler {
337    /// Run the full call sequence: alloc → write → call → dealloc → result.
338    ///
339    /// `dcli_dealloc` is invoked on every exit path — including when the
340    /// business function itself returns a non-zero error code — so the
341    /// guest never accumulates unfreed buffers across repeated calls.
342    fn call_guest(&self, args: &HashMap<String, String>) -> Result<()> {
343        let mut store = Store::new(&self.engine, ());
344        let instance =
345            Instance::new(&mut store, &self.module, &[]).map_err(|e| WasmError::LoadFailed {
346                path: std::path::PathBuf::from(&self.module_label),
347                source: e.into(),
348                suggestion: Some("Failed to instantiate the WASM module.".to_string()),
349            })?;
350
351        let memory = instance
352            .get_memory(&mut store, EXPORT_MEMORY)
353            .ok_or_else(|| {
354                WasmError::missing_mandatory_export(EXPORT_MEMORY, &self.module_label)
355            })?;
356
357        let alloc = instance
358            .get_typed_func::<i32, i32>(&mut store, EXPORT_ALLOC)
359            .map_err(|_| WasmError::missing_mandatory_export(EXPORT_ALLOC, &self.module_label))?;
360
361        let dealloc = instance
362            .get_typed_func::<(i32, i32), ()>(&mut store, EXPORT_DEALLOC)
363            .map_err(|_| WasmError::missing_mandatory_export(EXPORT_DEALLOC, &self.module_label))?;
364
365        let business_fn = instance
366            .get_typed_func::<(i32, i32), i32>(&mut store, self.wasm_fn_name.as_str())
367            .map_err(|_| WasmError::FunctionNotFound {
368                function: self.wasm_fn_name.clone(),
369                module: self.module_label.clone(),
370                suggestion: Some(format!(
371                    "Export `fn {}(ptr: i32, len: i32) -> i32` from the WASM module, \
372                     or check the name passed to `with_function_map`.",
373                    self.wasm_fn_name
374                )),
375            })?;
376
377        // Serialize arguments and request guest-owned buffer space.
378        let payload = self.format.serialize(args)?;
379        let len = payload.len() as i32;
380
381        let ptr = alloc
382            .call(&mut store, len)
383            .map_err(|e| WasmError::MemoryAccessFailed {
384                reason: e.to_string(),
385            })?;
386
387        memory
388            .write(&mut store, ptr as usize, &payload)
389            .map_err(|e| WasmError::MemoryAccessFailed {
390                reason: e.to_string(),
391            })?;
392
393        // Invoke the business function. Regardless of outcome, the guest
394        // buffer is freed before this function returns (see below).
395        let call_result = business_fn.call(&mut store, (ptr, len));
396
397        // Always free the buffer we allocated, on every exit path.
398        let dealloc_result = dealloc.call(&mut store, (ptr, len));
399
400        let code = call_result.map_err(|e| WasmError::MemoryAccessFailed {
401            reason: format!("business function trapped: {e}"),
402        })?;
403
404        // A failure to deallocate is logged via the returned error only if
405        // the business call itself succeeded — otherwise the guest error
406        // takes priority as the more actionable failure.
407        if code == 0 {
408            dealloc_result.map_err(|e| WasmError::MemoryAccessFailed {
409                reason: format!("dcli_dealloc failed: {e}"),
410            })?;
411            return Ok(());
412        }
413
414        // Non-zero return code: attempt to retrieve a detailed message via
415        // the optional `dcli_last_error_message` export.
416        //
417        // dealloc_result is intentionally not surfaced here even if it
418        // failed: the guest's own error code is the more actionable signal
419        // for the caller, and a secondary dealloc failure on an already
420        // failing call would only obscure it.
421        let message = self.read_last_error_message(&mut store, &instance);
422        Err(WasmError::GuestError { code, message }.into())
423    }
424
425    /// Best-effort retrieval of a detailed error message from the guest.
426    ///
427    /// Returns `None` when the module does not export
428    /// `dcli_last_error_message`, or when reading the message fails for any
429    /// reason. A missing or unreadable message degrades to the raw error
430    /// code — it never escalates into a separate error of its own.
431    fn read_last_error_message(
432        &self,
433        store: &mut Store<()>,
434        instance: &Instance,
435    ) -> Option<String> {
436        let last_error_fn = instance
437            .get_typed_func::<(), (i32, i32)>(&mut *store, EXPORT_LAST_ERROR)
438            .ok()?;
439        let (ptr, len) = last_error_fn.call(&mut *store, ()).ok()?;
440        if len <= 0 {
441            return None;
442        }
443        let memory = instance.get_memory(&mut *store, EXPORT_MEMORY)?;
444        let mut buf = vec![0u8; len as usize];
445        memory.read(&mut *store, ptr as usize, &mut buf).ok()?;
446        String::from_utf8(buf).ok()
447    }
448}
449
450impl CommandHandler for WasmHandler {
451    fn execute(&self, _ctx: &mut dyn ExecutionContext, args: &ParsedArgs) -> Result<()> {
452        // ExecutionContext is intentionally not forwarded to the guest —
453        // see the module-level "Known limitation" section.
454        //
455        // The WASM ABI (DD-021) predates repeatable options (DD-024) and
456        // has not been extended to represent them across the host/guest
457        // boundary; any `ParsedValue::Repeated` entry is silently dropped
458        // by `to_scalar_map()`, same rationale as the REPL path.
459        self.call_guest(&args.to_scalar_map())
460    }
461}
462
463// ============================================================================
464// Tests
465// ============================================================================
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use std::any::Any;
471    use std::path::PathBuf;
472
473    #[derive(Default)]
474    struct TestContext;
475
476    impl ExecutionContext for TestContext {
477        fn as_any(&self) -> &dyn Any {
478            self
479        }
480        fn as_any_mut(&mut self) -> &mut dyn Any {
481            self
482        }
483    }
484
485    /// A minimal valid module: memory + dcli_alloc + dcli_dealloc + a
486    /// business function `ok_handler` that always returns 0 (success).
487    ///
488    /// Allocation/deallocation are no-ops here (a fixed bump pointer at
489    /// offset 1024) — sufficient for ABI-contract tests, not a realistic
490    /// allocator.
491    const WAT_MINIMAL_OK: &str = r#"
492        (module
493            (memory (export "memory") 1)
494            (func (export "dcli_alloc") (param i32) (result i32)
495                i32.const 1024)
496            (func (export "dcli_dealloc") (param i32 i32))
497            (func (export "ok_handler") (param i32 i32) (result i32)
498                i32.const 0)
499        )
500    "#;
501
502    /// Same as above, but the business function always returns error code 1,
503    /// with no `dcli_last_error_message` export.
504    const WAT_MINIMAL_ERROR: &str = r#"
505        (module
506            (memory (export "memory") 1)
507            (func (export "dcli_alloc") (param i32) (result i32)
508                i32.const 1024)
509            (func (export "dcli_dealloc") (param i32 i32))
510            (func (export "err_handler") (param i32 i32) (result i32)
511                i32.const 1)
512        )
513    "#;
514
515    /// Same as the error module, but also exports `dcli_last_error_message`
516    /// pointing at a fixed "boom" string baked into a data segment.
517    const WAT_WITH_ERROR_MESSAGE: &str = r#"
518        (module
519            (memory (export "memory") 1)
520            (data (i32.const 2048) "boom")
521            (func (export "dcli_alloc") (param i32) (result i32)
522                i32.const 1024)
523            (func (export "dcli_dealloc") (param i32 i32))
524            (func (export "err_handler") (param i32 i32) (result i32)
525                i32.const 1)
526            (func (export "dcli_last_error_message") (result i32 i32)
527                i32.const 2048
528                i32.const 4)
529        )
530    "#;
531
532    /// Missing `dcli_dealloc` entirely.
533    const WAT_MISSING_DEALLOC: &str = r#"
534        (module
535            (memory (export "memory") 1)
536            (func (export "dcli_alloc") (param i32) (result i32)
537                i32.const 1024)
538            (func (export "ok_handler") (param i32 i32) (result i32)
539                i32.const 0)
540        )
541    "#;
542
543    /// Missing `memory` entirely.
544    const WAT_MISSING_MEMORY: &str = r#"
545        (module
546            (func (export "dcli_alloc") (param i32) (result i32)
547                i32.const 1024)
548            (func (export "dcli_dealloc") (param i32 i32))
549            (func (export "ok_handler") (param i32 i32) (result i32)
550                i32.const 0)
551        )
552    "#;
553
554    fn load_wat(wat: &str) -> Result<WasmPlugin> {
555        WasmPlugin::from_bytes(wat.as_bytes(), &PathBuf::from("test.wat"))
556    }
557
558    // -------------------------------------------------------------------------
559    // Loading & mandatory export validation
560    // -------------------------------------------------------------------------
561
562    #[test]
563    fn test_load_minimal_valid_module_succeeds() {
564        let plugin = load_wat(WAT_MINIMAL_OK);
565        assert!(plugin.is_ok());
566    }
567
568    #[test]
569    fn test_load_missing_dealloc_fails() {
570        let result = load_wat(WAT_MISSING_DEALLOC);
571        assert!(result.is_err());
572    }
573
574    #[test]
575    fn test_load_missing_memory_fails() {
576        let result = load_wat(WAT_MISSING_MEMORY);
577        assert!(result.is_err());
578    }
579
580    #[test]
581    fn test_load_invalid_bytes_fails() {
582        let result = WasmPlugin::from_bytes(b"not a wasm module", &PathBuf::from("bad.wasm"));
583        assert!(result.is_err());
584    }
585
586    #[test]
587    fn test_default_metadata_derived_from_filename() {
588        let plugin = WasmPlugin::from_bytes(
589            WAT_MINIMAL_OK.as_bytes(),
590            &PathBuf::from("plugins/greet.wat"),
591        )
592        .unwrap();
593        assert_eq!(plugin.name(), "greet");
594    }
595
596    // -------------------------------------------------------------------------
597    // Metadata builder methods
598    // -------------------------------------------------------------------------
599
600    #[test]
601    fn test_with_metadata_overrides_defaults() {
602        let plugin =
603            load_wat(WAT_MINIMAL_OK)
604                .unwrap()
605                .with_metadata("custom", "2.5.0", "A custom plugin");
606        assert_eq!(plugin.name(), "custom");
607        assert_eq!(plugin.version(), "2.5.0");
608        assert_eq!(plugin.description(), "A custom plugin");
609    }
610
611    #[test]
612    fn test_with_format_defaults_to_yaml() {
613        let plugin = load_wat(WAT_MINIMAL_OK).unwrap();
614        assert_eq!(plugin.format, WasmSerializationFormat::Yaml);
615    }
616
617    #[test]
618    fn test_with_format_can_be_set_to_json() {
619        let plugin = load_wat(WAT_MINIMAL_OK)
620            .unwrap()
621            .with_format(WasmSerializationFormat::Json);
622        assert_eq!(plugin.format, WasmSerializationFormat::Json);
623    }
624
625    // -------------------------------------------------------------------------
626    // Plugin::handlers()
627    // -------------------------------------------------------------------------
628
629    #[test]
630    fn test_handlers_reflects_function_map() {
631        let plugin = load_wat(WAT_MINIMAL_OK)
632            .unwrap()
633            .with_function_map("my_command", "ok_handler");
634        let handlers = plugin.handlers();
635        assert_eq!(handlers.len(), 1);
636        assert_eq!(handlers[0].0, "my_command");
637    }
638
639    #[test]
640    fn test_handlers_empty_without_function_map() {
641        let plugin = load_wat(WAT_MINIMAL_OK).unwrap();
642        assert_eq!(plugin.handlers().len(), 0);
643    }
644
645    // -------------------------------------------------------------------------
646    // Execution — success path
647    // -------------------------------------------------------------------------
648
649    #[test]
650    fn test_execute_success_returns_ok() {
651        let plugin = load_wat(WAT_MINIMAL_OK)
652            .unwrap()
653            .with_function_map("cmd", "ok_handler");
654        let handlers = plugin.handlers();
655        let (_, handler) = &handlers[0];
656
657        let mut ctx = TestContext;
658        let args = ParsedArgs::from_scalars(HashMap::new());
659        assert!(handler.execute(&mut ctx, &args).is_ok());
660    }
661
662    #[test]
663    fn test_execute_with_args_serializes_without_error() {
664        let plugin = load_wat(WAT_MINIMAL_OK)
665            .unwrap()
666            .with_function_map("cmd", "ok_handler");
667        let handlers = plugin.handlers();
668        let (_, handler) = &handlers[0];
669
670        let mut ctx = TestContext;
671        let mut args = HashMap::new();
672        args.insert("name".to_string(), "World".to_string());
673        args.insert("count".to_string(), "3".to_string());
674        let args = ParsedArgs::from_scalars(args);
675        assert!(handler.execute(&mut ctx, &args).is_ok());
676    }
677
678    #[test]
679    fn test_execute_repeated_calls_do_not_exhaust_guest() {
680        // Verifies that dcli_dealloc being called on every exit path allows
681        // the same handler to be invoked many times without issue — a
682        // regression test for the "always deallocate" requirement.
683        let plugin = load_wat(WAT_MINIMAL_OK)
684            .unwrap()
685            .with_function_map("cmd", "ok_handler");
686        let handlers = plugin.handlers();
687        let (_, handler) = &handlers[0];
688
689        let mut ctx = TestContext;
690        for _ in 0..50 {
691            let args = ParsedArgs::from_scalars(HashMap::new());
692            assert!(handler.execute(&mut ctx, &args).is_ok());
693        }
694    }
695
696    // -------------------------------------------------------------------------
697    // Execution — error path
698    // -------------------------------------------------------------------------
699
700    #[test]
701    fn test_execute_guest_error_without_message() {
702        let plugin = load_wat(WAT_MINIMAL_ERROR)
703            .unwrap()
704            .with_function_map("cmd", "err_handler");
705        let handlers = plugin.handlers();
706        let (_, handler) = &handlers[0];
707
708        let mut ctx = TestContext;
709        let args = ParsedArgs::from_scalars(HashMap::new());
710        let result = handler.execute(&mut ctx, &args);
711        assert!(result.is_err());
712
713        match result.unwrap_err() {
714            crate::error::DynamicCliError::Wasm(WasmError::GuestError { code, message }) => {
715                assert_eq!(code, 1);
716                assert!(message.is_none());
717            }
718            other => panic!("unexpected error variant: {other:?}"),
719        }
720    }
721
722    #[test]
723    fn test_execute_guest_error_with_message() {
724        let plugin = load_wat(WAT_WITH_ERROR_MESSAGE)
725            .unwrap()
726            .with_function_map("cmd", "err_handler");
727        let handlers = plugin.handlers();
728        let (_, handler) = &handlers[0];
729
730        let mut ctx = TestContext;
731        let args = ParsedArgs::from_scalars(HashMap::new());
732        let result = handler.execute(&mut ctx, &args);
733        assert!(result.is_err());
734
735        match result.unwrap_err() {
736            crate::error::DynamicCliError::Wasm(WasmError::GuestError { code, message }) => {
737                assert_eq!(code, 1);
738                assert_eq!(message, Some("boom".to_string()));
739            }
740            other => panic!("unexpected error variant: {other:?}"),
741        }
742    }
743
744    #[test]
745    fn test_execute_unmapped_function_name_fails() {
746        let plugin = load_wat(WAT_MINIMAL_OK)
747            .unwrap()
748            .with_function_map("cmd", "does_not_exist");
749        let handlers = plugin.handlers();
750        let (_, handler) = &handlers[0];
751
752        let mut ctx = TestContext;
753        let args = ParsedArgs::from_scalars(HashMap::new());
754        assert!(handler.execute(&mut ctx, &args).is_err());
755    }
756
757    // -------------------------------------------------------------------------
758    // Thread safety
759    // -------------------------------------------------------------------------
760
761    #[test]
762    fn test_wasm_plugin_is_send_sync() {
763        fn assert_send_sync<T: Send + Sync>() {}
764        assert_send_sync::<WasmPlugin>();
765    }
766}