Skip to main content

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}