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}