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