sen_plugin_sdk/lib.rs
1//! sen-plugin-sdk: SDK for creating WASM plugins
2//!
3//! This SDK provides utilities and helpers for creating WASM plugins
4//! with minimal boilerplate. Using this SDK, you can create a fully functional
5//! plugin in under 30 lines of code.
6//!
7//! # Table of Contents
8//!
9//! - [Project Setup](#project-setup)
10//! - [Quick Start](#quick-start)
11//! - [Arguments](#arguments)
12//! - [Error Handling](#error-handling)
13//! - [Advanced Usage](#advanced-usage)
14//! - [Manual Implementation](#manual-implementation)
15//! - [Best Practices](#best-practices)
16//! - [Troubleshooting](#troubleshooting)
17//!
18//! # Project Setup
19//!
20//! ## 1. Create a New Plugin Project
21//!
22//! ```bash
23//! cargo new --lib my-plugin
24//! cd my-plugin
25//! ```
26//!
27//! ## 2. Configure Cargo.toml
28//!
29//! Your complete `Cargo.toml` should look like:
30//!
31//! ```toml
32//! [package]
33//! name = "my-plugin"
34//! version = "0.1.0"
35//! edition = "2021"
36//!
37//! [lib]
38//! crate-type = ["cdylib"] # Required for WASM output
39//!
40//! [dependencies]
41//! sen-plugin-sdk = { version = "0.7" }
42//!
43//! # Optimize for size (optional but recommended)
44//! [profile.release]
45//! opt-level = "s"
46//! lto = true
47//! strip = true
48//! ```
49//!
50//! ## 3. Install WASM Target (One-Time)
51//!
52//! ```bash
53//! rustup target add wasm32-unknown-unknown
54//! ```
55//!
56//! ## 4. Build Your Plugin
57//!
58//! ```bash
59//! cargo build --release --target wasm32-unknown-unknown
60//! ```
61//!
62//! The output file will be at:
63//! `target/wasm32-unknown-unknown/release/my_plugin.wasm`
64//!
65//! # Quick Start
66//!
67//! A minimal plugin requires:
68//! 1. A struct implementing the [`Plugin`] trait
69//! 2. The [`export_plugin!`] macro to generate WASM exports
70//!
71//! ```rust,ignore
72//! use sen_plugin_sdk::prelude::*;
73//!
74//! struct HelloPlugin;
75//!
76//! impl Plugin for HelloPlugin {
77//! fn manifest() -> PluginManifest {
78//! PluginManifest::new(
79//! CommandSpec::new("hello", "Says hello to the world")
80//! .version("1.0.0")
81//! .arg(ArgSpec::positional("name").help("Name to greet"))
82//! )
83//! }
84//!
85//! fn execute(args: Vec<String>) -> ExecuteResult {
86//! let name = args.first().map(|s| s.as_str()).unwrap_or("World");
87//! ExecuteResult::success(format!("Hello, {}!", name))
88//! }
89//! }
90//!
91//! export_plugin!(HelloPlugin);
92//! ```
93//!
94//! # Arguments
95//!
96//! ## Positional Arguments
97//!
98//! Positional arguments are passed in order:
99//!
100//! ```rust,ignore
101//! CommandSpec::new("copy", "Copy files")
102//! .arg(ArgSpec::positional("source").required().help("Source file"))
103//! .arg(ArgSpec::positional("dest").required().help("Destination file"))
104//! ```
105//!
106//! Usage: `copy src.txt dst.txt`
107//!
108//! In `execute()`, args are: `["src.txt", "dst.txt"]`
109//!
110//! ## Options (Flags with Values)
111//!
112//! Named options with long and short forms:
113//!
114//! ```rust,ignore
115//! CommandSpec::new("greet", "Greet someone")
116//! .arg(ArgSpec::positional("name").default("World"))
117//! .arg(
118//! ArgSpec::option("greeting", "greeting")
119//! .short('g')
120//! .help("Custom greeting message")
121//! .default("Hello")
122//! )
123//! .arg(
124//! ArgSpec::option("count", "count")
125//! .short('n')
126//! .help("Number of times to greet")
127//! .default("1")
128//! )
129//! ```
130//!
131//! Usage: `greet Alice -g "Good morning" --count 3`
132//!
133//! ## Required Arguments
134//!
135//! Mark arguments as required:
136//!
137//! ```rust,ignore
138//! ArgSpec::positional("file")
139//! .required()
140//! .help("Input file (required)")
141//! ```
142//!
143//! ## Default Values
144//!
145//! Provide fallback values:
146//!
147//! ```rust,ignore
148//! ArgSpec::option("format", "format")
149//! .short('f')
150//! .default("json")
151//! .help("Output format [default: json]")
152//! ```
153//!
154//! ## Argument Parsing in execute()
155//!
156//! Arguments are passed as a `Vec<String>` in the order they appear.
157//! The host handles option parsing; your plugin receives resolved values:
158//!
159//! ```rust,ignore
160//! fn execute(args: Vec<String>) -> ExecuteResult {
161//! // For: greet Alice -g "Hi"
162//! // args = ["Alice", "Hi"]
163//!
164//! let name = args.get(0).map(|s| s.as_str()).unwrap_or("World");
165//! let greeting = args.get(1).map(|s| s.as_str()).unwrap_or("Hello");
166//!
167//! ExecuteResult::success(format!("{}, {}!", greeting, name))
168//! }
169//! ```
170//!
171//! # Error Handling
172//!
173//! Plugins return [`ExecuteResult`] which can be:
174//!
175//! ## Success
176//!
177//! ```rust,ignore
178//! ExecuteResult::success("Operation completed successfully")
179//! ```
180//!
181//! ## User Error (Exit Code 1)
182//!
183//! For expected errors like invalid input:
184//!
185//! ```rust,ignore
186//! fn execute(args: Vec<String>) -> ExecuteResult {
187//! let file = match args.first() {
188//! Some(f) => f,
189//! None => return ExecuteResult::user_error("Missing required argument: file"),
190//! };
191//!
192//! if !is_valid_format(file) {
193//! return ExecuteResult::user_error(format!(
194//! "Invalid file format: {}. Expected .json or .yaml",
195//! file
196//! ));
197//! }
198//!
199//! ExecuteResult::success("File processed")
200//! }
201//! ```
202//!
203//! ## System Error (Exit Code 101)
204//!
205//! For unexpected internal errors:
206//!
207//! ```rust,ignore
208//! fn execute(args: Vec<String>) -> ExecuteResult {
209//! match process_data(&args) {
210//! Ok(result) => ExecuteResult::success(result),
211//! Err(e) => ExecuteResult::system_error(format!("Internal error: {}", e)),
212//! }
213//! }
214//! ```
215//!
216//! # Advanced Usage
217//!
218//! ## Subcommands
219//!
220//! Create nested command structures:
221//!
222//! ```rust,ignore
223//! CommandSpec::new("db", "Database operations")
224//! .subcommand(
225//! CommandSpec::new("create", "Create a new database")
226//! .arg(ArgSpec::positional("name").required())
227//! )
228//! .subcommand(
229//! CommandSpec::new("drop", "Drop a database")
230//! .arg(ArgSpec::positional("name").required())
231//! )
232//! .subcommand(
233//! CommandSpec::new("list", "List all databases")
234//! )
235//! ```
236//!
237//! ## Plugin Metadata
238//!
239//! Add author and version information:
240//!
241//! ```rust,ignore
242//! CommandSpec::new("mytool", "My awesome tool")
243//! .version("2.1.0")
244//! // Note: author is set on CommandSpec, not PluginManifest
245//! ```
246//!
247//! # Manual Implementation
248//!
249//! If you need more control, you can implement the WASM exports manually
250//! instead of using the SDK. This is what the `export_plugin!` macro generates:
251//!
252//! ```rust,ignore
253//! use sen_plugin_api::{ArgSpec, CommandSpec, ExecuteResult, PluginManifest, API_VERSION};
254//! use std::alloc::{alloc, dealloc, Layout};
255//!
256//! // 1. Memory allocator for host-guest communication
257//! #[no_mangle]
258//! pub extern "C" fn plugin_alloc(size: i32) -> i32 {
259//! if size <= 0 { return 0; }
260//! let layout = Layout::from_size_align(size as usize, 1).unwrap();
261//! unsafe { alloc(layout) as i32 }
262//! }
263//!
264//! // 2. Memory deallocator
265//! #[no_mangle]
266//! pub extern "C" fn plugin_dealloc(ptr: i32, size: i32) {
267//! if ptr == 0 || size <= 0 { return; }
268//! let layout = Layout::from_size_align(size as usize, 1).unwrap();
269//! unsafe { dealloc(ptr as *mut u8, layout) }
270//! }
271//!
272//! // 3. Return plugin manifest (command specification)
273//! #[no_mangle]
274//! pub extern "C" fn plugin_manifest() -> i64 {
275//! let manifest = PluginManifest {
276//! api_version: API_VERSION,
277//! command: CommandSpec::new("hello", "Says hello")
278//! .arg(ArgSpec::positional("name").default("World")),
279//! };
280//! serialize_to_memory(&manifest)
281//! }
282//!
283//! // 4. Execute the command
284//! #[no_mangle]
285//! pub extern "C" fn plugin_execute(args_ptr: i32, args_len: i32) -> i64 {
286//! let args: Vec<String> = unsafe {
287//! let slice = std::slice::from_raw_parts(args_ptr as *const u8, args_len as usize);
288//! rmp_serde::from_slice(slice).unwrap_or_default()
289//! };
290//!
291//! let name = args.first().map(|s| s.as_str()).unwrap_or("World");
292//! let result = ExecuteResult::success(format!("Hello, {}!", name));
293//! serialize_to_memory(&result)
294//! }
295//!
296//! // Helper: Pack pointer and length into i64
297//! fn pack_ptr_len(ptr: i32, len: i32) -> i64 {
298//! ((ptr as i64) << 32) | (len as i64 & 0xFFFFFFFF)
299//! }
300//!
301//! // Helper: Serialize value to guest memory
302//! fn serialize_to_memory<T: serde::Serialize>(value: &T) -> i64 {
303//! let bytes = rmp_serde::to_vec(value).expect("Serialization failed");
304//! let len = bytes.len() as i32;
305//! let ptr = plugin_alloc(len);
306//! if ptr == 0 { return 0; }
307//! unsafe {
308//! std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr as *mut u8, bytes.len());
309//! }
310//! pack_ptr_len(ptr, len)
311//! }
312//! ```
313//!
314//! # Best Practices
315//!
316//! ## Do
317//!
318//! - **Keep plugins focused**: One plugin, one responsibility
319//! - **Validate inputs early**: Check arguments at the start of `execute()`
320//! - **Return meaningful errors**: Include context in error messages
321//! - **Use default values**: Make common cases convenient
322//! - **Document your commands**: Use `.help()` on all arguments
323//!
324//! ## Don't
325//!
326//! - **Don't panic**: Always return `ExecuteResult::user_error` or `system_error`
327//! - **Don't use unwrap()**: Prefer `unwrap_or`, `unwrap_or_default`, or match
328//! - **Don't allocate excessively**: WASM has limited memory
329//! - **Don't block forever**: The host has CPU limits (fuel)
330//!
331//! ## Example: Robust Argument Handling
332//!
333//! ```rust,ignore
334//! fn execute(args: Vec<String>) -> ExecuteResult {
335//! // Validate required arguments
336//! let file = match args.get(0) {
337//! Some(f) if !f.is_empty() => f,
338//! _ => return ExecuteResult::user_error("Missing required argument: file"),
339//! };
340//!
341//! // Parse optional numeric argument with default
342//! let count: usize = args.get(1)
343//! .and_then(|s| s.parse().ok())
344//! .unwrap_or(1);
345//!
346//! // Validate value range
347//! if count == 0 || count > 100 {
348//! return ExecuteResult::user_error(
349//! "Count must be between 1 and 100"
350//! );
351//! }
352//!
353//! ExecuteResult::success(format!("Processing {} {} time(s)", file, count))
354//! }
355//! ```
356//!
357//! # Troubleshooting
358//!
359//! ## Build Errors
360//!
361//! **Error: `can't find crate for std`**
362//!
363//! Make sure you're building for the correct target:
364//! ```bash
365//! cargo build --release --target wasm32-unknown-unknown
366//! ```
367//!
368//! **Error: `crate-type must be cdylib`**
369//!
370//! Add to your `Cargo.toml`:
371//! ```toml
372//! [lib]
373//! crate-type = ["cdylib"]
374//! ```
375//!
376//! ## Runtime Errors
377//!
378//! **Error: `API version mismatch`**
379//!
380//! Your plugin was built with a different API version. Rebuild with the
381//! matching `sen-plugin-sdk` version.
382//!
383//! **Error: `Function not found: plugin_manifest`**
384//!
385//! Make sure you have `export_plugin!(YourPlugin);` at the end of your lib.rs.
386//!
387//! **Error: `Fuel exhausted`**
388//!
389//! Your plugin is taking too long (possible infinite loop). The host limits
390//! CPU usage to prevent runaway plugins.
391//!
392//! ## Debugging Tips
393//!
394//! 1. **Test locally first**: Write unit tests for your `execute()` logic
395//! 2. **Check WASM size**: Large plugins may have unnecessary dependencies
396//! 3. **Simplify arguments**: Start with positional args, add options later
397//!
398//! # Examples
399//!
400//! See the `examples/` directory for complete working plugins:
401//!
402//! - `examples/hello-plugin/`: Manual implementation (no SDK)
403//! - `examples/greet-plugin/`: SDK-based with options
404
405use std::alloc::{alloc, dealloc, Layout};
406
407// Re-export everything from sen-plugin-api
408pub use sen_plugin_api::*;
409
410/// Prelude module for convenient imports
411pub mod prelude {
412 pub use crate::{export_plugin, memory, Plugin};
413 pub use sen_plugin_api::{
414 ArgSpec, CommandSpec, ExecuteError, ExecuteResult, PluginManifest, API_VERSION,
415 };
416}
417
418/// Trait that plugins must implement
419pub trait Plugin {
420 /// Returns the plugin manifest describing the command
421 fn manifest() -> PluginManifest;
422
423 /// Executes the plugin with the given arguments
424 fn execute(args: Vec<String>) -> ExecuteResult;
425}
426
427/// Memory utilities for Wasm plugin development
428///
429/// # Platform
430/// These functions are designed for **WASM32 targets only**.
431/// Pointer values are represented as `i32`, which is correct for WASM32's
432/// 32-bit linear memory address space. Do not use on 64-bit native targets.
433pub mod memory {
434 use super::*;
435
436 /// Allocate memory in the Wasm linear memory
437 ///
438 /// # Platform
439 /// WASM32 only. Pointer is returned as `i32` (32-bit address).
440 ///
441 /// # Returns
442 /// - Pointer to allocated memory as `i32`
443 /// - `0` (null pointer) on allocation failure or invalid size
444 ///
445 /// # Safety
446 /// This function is safe to call from the host.
447 #[inline]
448 pub fn plugin_alloc(size: i32) -> i32 {
449 if size <= 0 {
450 return 0;
451 }
452 // Safe: size > 0 is checked above, and positive i32 always fits in usize
453 let size_usize = size as usize;
454 let layout = match Layout::from_size_align(size_usize, 1) {
455 Ok(l) => l,
456 Err(_) => return 0, // Invalid layout, return null pointer
457 };
458 // SAFETY:
459 // 1. Layout is valid (checked above with from_size_align)
460 // 2. Layout has non-zero size (size > 0 checked above)
461 // 3. The returned pointer will be properly aligned (alignment = 1)
462 unsafe { alloc(layout) as i32 }
463 }
464
465 /// Deallocate memory in the Wasm linear memory
466 ///
467 /// # Safety
468 /// The ptr must have been allocated by `plugin_alloc` with the same size.
469 #[inline]
470 pub fn plugin_dealloc(ptr: i32, size: i32) {
471 if ptr == 0 || size <= 0 {
472 return;
473 }
474 // Safe: size > 0 is checked above
475 let size_usize = size as usize;
476 let layout = match Layout::from_size_align(size_usize, 1) {
477 Ok(l) => l,
478 Err(_) => return, // Invalid layout, skip deallocation
479 };
480 // SAFETY:
481 // 1. ptr was allocated by plugin_alloc with the same layout (caller's responsibility)
482 // 2. ptr is non-null (checked above: ptr == 0 returns early)
483 // 3. Layout matches the allocation (same size, alignment = 1)
484 // 4. The memory block has not been deallocated yet (caller's responsibility)
485 unsafe { dealloc(ptr as *mut u8, layout) }
486 }
487
488 /// Pack a pointer and length into a single i64 value
489 ///
490 /// This is the standard way to return two values from a Wasm function
491 /// since wasm32-unknown-unknown doesn't support multi-value returns.
492 #[inline]
493 pub fn pack_ptr_len(ptr: i32, len: i32) -> i64 {
494 ((ptr as i64) << 32) | (len as i64 & 0xFFFFFFFF)
495 }
496
497 /// Serialize data and return it as an allocated buffer
498 ///
499 /// Returns a packed i64 containing the pointer and length.
500 /// Returns (0, 0) on serialization failure or if data exceeds i32::MAX bytes.
501 pub fn serialize_and_return<T: serde::Serialize>(data: &T) -> i64 {
502 let bytes = match rmp_serde::to_vec(data) {
503 Ok(b) => b,
504 Err(_) => return pack_ptr_len(0, 0),
505 };
506
507 // Check for integer overflow before casting
508 let len: i32 = match bytes.len().try_into() {
509 Ok(l) => l,
510 Err(_) => return pack_ptr_len(0, 0), // Data too large for i32
511 };
512
513 let ptr = plugin_alloc(len);
514
515 if ptr != 0 && len > 0 {
516 // SAFETY:
517 // 1. src (bytes.as_ptr()) is valid for reads of len bytes
518 // 2. dst (ptr) is valid for writes of len bytes (allocated by plugin_alloc)
519 // 3. Both pointers are properly aligned (alignment = 1 for u8)
520 // 4. Memory regions do not overlap (src is stack/heap, dst is Wasm linear memory)
521 unsafe {
522 std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr as *mut u8, len as usize);
523 }
524 }
525
526 pack_ptr_len(ptr, len)
527 }
528
529 /// Error type for deserialization failures
530 #[derive(Debug)]
531 pub enum DeserializeError {
532 /// Null pointer or invalid length provided
533 InvalidPointer { ptr: i32, len: i32 },
534 /// MessagePack deserialization failed
535 DeserializeFailed(rmp_serde::decode::Error),
536 }
537
538 impl std::fmt::Display for DeserializeError {
539 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
540 match self {
541 Self::InvalidPointer { ptr, len } => {
542 write!(f, "invalid pointer/length: ptr={}, len={}", ptr, len)
543 }
544 Self::DeserializeFailed(e) => write!(f, "deserialization failed: {}", e),
545 }
546 }
547 }
548
549 impl std::error::Error for DeserializeError {
550 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
551 match self {
552 Self::DeserializeFailed(e) => Some(e),
553 _ => None,
554 }
555 }
556 }
557
558 /// Deserialize data from a raw pointer and length
559 ///
560 /// # Platform
561 /// WASM32 only. Expects pointer as `i32` (32-bit address).
562 ///
563 /// # Errors
564 /// - `InvalidPointer` if ptr is 0 or len <= 0
565 /// - `DeserializeFailed` if MessagePack deserialization fails
566 ///
567 /// # Safety
568 /// Caller must ensure:
569 /// 1. `ptr` points to a valid memory region in Wasm linear memory
570 /// 2. The memory region is at least `len` bytes
571 /// 3. The memory contains valid MessagePack data
572 /// 4. The memory will not be modified during deserialization
573 pub unsafe fn deserialize_from_ptr<T: serde::de::DeserializeOwned>(
574 ptr: i32,
575 len: i32,
576 ) -> Result<T, DeserializeError> {
577 if ptr == 0 || len <= 0 {
578 return Err(DeserializeError::InvalidPointer { ptr, len });
579 }
580 // SAFETY: Caller guarantees ptr is valid for len bytes (see function docs)
581 let slice = std::slice::from_raw_parts(ptr as *const u8, len as usize);
582 rmp_serde::from_slice(slice).map_err(DeserializeError::DeserializeFailed)
583 }
584}
585
586/// Macro to export all required plugin functions
587///
588/// This macro generates the `plugin_manifest`, `plugin_execute`, `plugin_alloc`,
589/// and `plugin_dealloc` functions required by the host.
590///
591/// # Example
592///
593/// ```rust,ignore
594/// struct MyPlugin;
595///
596/// impl Plugin for MyPlugin {
597/// fn manifest() -> PluginManifest { /* ... */ }
598/// fn execute(args: Vec<String>) -> ExecuteResult { /* ... */ }
599/// }
600///
601/// export_plugin!(MyPlugin);
602/// ```
603#[macro_export]
604macro_rules! export_plugin {
605 ($plugin:ty) => {
606 #[no_mangle]
607 pub extern "C" fn plugin_manifest() -> i64 {
608 let manifest = <$plugin as $crate::Plugin>::manifest();
609 $crate::memory::serialize_and_return(&manifest)
610 }
611
612 #[no_mangle]
613 pub extern "C" fn plugin_execute(args_ptr: i32, args_len: i32) -> i64 {
614 let args: Vec<String> = unsafe {
615 match $crate::memory::deserialize_from_ptr(args_ptr, args_len) {
616 Ok(v) => v,
617 Err(_e) => {
618 // Return error result for invalid/corrupted arguments
619 let result =
620 $crate::ExecuteResult::system_error("Failed to deserialize arguments");
621 return $crate::memory::serialize_and_return(&result);
622 }
623 }
624 };
625 let result = <$plugin as $crate::Plugin>::execute(args);
626 $crate::memory::serialize_and_return(&result)
627 }
628
629 #[no_mangle]
630 pub extern "C" fn plugin_alloc(size: i32) -> i32 {
631 $crate::memory::plugin_alloc(size)
632 }
633
634 #[no_mangle]
635 pub extern "C" fn plugin_dealloc(ptr: i32, size: i32) {
636 $crate::memory::plugin_dealloc(ptr, size)
637 }
638 };
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 #[test]
646 fn test_pack_ptr_len() {
647 let ptr = 0x12345678_i32;
648 let len = 0x00000100_i32;
649 let packed = memory::pack_ptr_len(ptr, len);
650
651 // Verify the packed value
652 let unpacked_ptr = (packed >> 32) as i32;
653 let unpacked_len = (packed & 0xFFFFFFFF) as i32;
654
655 assert_eq!(unpacked_ptr, ptr);
656 assert_eq!(unpacked_len, len);
657 }
658
659 #[test]
660 fn test_alloc_edge_cases() {
661 // Test zero/negative edge cases - these should return 0
662 assert_eq!(memory::plugin_alloc(0), 0);
663 assert_eq!(memory::plugin_alloc(-1), 0);
664 }
665
666 // Note: Full allocation tests run via integration tests with actual Wasm plugins.
667 // The memory functions are designed for Wasm linear memory and may behave
668 // differently in native test environments.
669}