Skip to main content

room_protocol/
plugin.rs

1//! Plugin framework types for the room chat system.
2//!
3//! This module defines the traits and types needed to implement a room plugin.
4//! External crates can depend on `room-protocol` alone to implement [`Plugin`]
5//! — no dependency on `room-cli` or broker internals is required.
6
7use std::future::Future;
8use std::pin::Pin;
9
10use chrono::{DateTime, Utc};
11
12use crate::{EventType, Message};
13
14/// Boxed future type used by [`Plugin::handle`] for dyn compatibility.
15pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
16
17// ── Plugin trait ────────────────────────────────────────────────────────────
18
19/// A plugin that handles one or more `/` commands and/or reacts to room
20/// lifecycle events.
21///
22/// Implement this trait and register it with the broker's plugin registry to
23/// add custom commands to a room. The broker dispatches matching
24/// `Message::Command` messages to the plugin's [`handle`](Plugin::handle)
25/// method, and calls [`on_user_join`](Plugin::on_user_join) /
26/// [`on_user_leave`](Plugin::on_user_leave) when users enter or leave.
27///
28/// Only [`name`](Plugin::name) and [`handle`](Plugin::handle) are required.
29/// All other methods have no-op / empty-vec defaults so that adding new
30/// lifecycle hooks in future releases does not break existing plugins.
31pub trait Plugin: Send + Sync {
32    /// Unique identifier for this plugin (e.g. `"stats"`, `"help"`).
33    fn name(&self) -> &str;
34
35    /// Semantic version of this plugin (e.g. `"1.0.0"`).
36    ///
37    /// Used for diagnostics and `/info` output. Defaults to `"0.0.0"` for
38    /// plugins that do not track their own version.
39    fn version(&self) -> &str {
40        "0.0.0"
41    }
42
43    /// Plugin API version this plugin was written against.
44    ///
45    /// The broker rejects plugins whose `api_version()` exceeds the current
46    /// [`PLUGIN_API_VERSION`]. Bump this constant when the `Plugin` trait
47    /// gains new required methods or changes existing method signatures.
48    ///
49    /// Defaults to `1` (the initial API revision).
50    fn api_version(&self) -> u32 {
51        1
52    }
53
54    /// Minimum `room-protocol` crate version this plugin requires, as a
55    /// semver string (e.g. `"3.1.0"`).
56    ///
57    /// The broker rejects plugins whose `min_protocol()` is newer than the
58    /// running `room-protocol` version. Defaults to `"0.0.0"` (compatible
59    /// with any protocol version).
60    fn min_protocol(&self) -> &str {
61        "0.0.0"
62    }
63
64    /// Commands this plugin handles. Each entry drives `/help` output
65    /// and TUI autocomplete.
66    ///
67    /// Defaults to an empty vec for plugins that only use lifecycle hooks
68    /// and do not register any commands.
69    fn commands(&self) -> Vec<CommandInfo> {
70        vec![]
71    }
72
73    /// Handle an invocation of one of this plugin's commands.
74    ///
75    /// Returns a boxed future for dyn compatibility (required because the
76    /// registry stores `Box<dyn Plugin>`).
77    fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>>;
78
79    /// Called after a user joins the room. The default is a no-op.
80    ///
81    /// Invoked synchronously during the join broadcast path. Implementations
82    /// must not block — spawn a task if async work is needed.
83    fn on_user_join(&self, _user: &str) {}
84
85    /// Called after a user leaves the room. The default is a no-op.
86    ///
87    /// Invoked synchronously during the leave broadcast path. Implementations
88    /// must not block — spawn a task if async work is needed.
89    fn on_user_leave(&self, _user: &str) {}
90
91    /// Called after every message is broadcast to the room. The default is a
92    /// no-op.
93    ///
94    /// Plugins can use this to observe message flow (e.g. tracking agent
95    /// activity for stale detection). Invoked synchronously after
96    /// `broadcast_and_persist` — implementations must not block.
97    fn on_message(&self, _msg: &Message) {}
98}
99
100/// Current Plugin API version. Increment when the `Plugin` trait changes in
101/// a way that requires plugin authors to update their code (new required
102/// methods, changed signatures, removed defaults).
103///
104/// Plugins returning an `api_version()` higher than this are rejected at
105/// registration.
106pub const PLUGIN_API_VERSION: u32 = 1;
107
108/// The `room-protocol` crate version, derived from `Cargo.toml` at compile
109/// time. Used by the broker to reject plugins that require a newer protocol
110/// than the one currently running.
111pub const PROTOCOL_VERSION: &str = env!("CARGO_PKG_VERSION");
112
113// ── C ABI for dynamic plugin loading ──────────────────────────────────────
114
115/// Types and conventions for loading plugins from `cdylib` shared libraries.
116///
117/// Each plugin cdylib exports two symbols:
118/// - [`DECLARATION_SYMBOL`]: a static [`PluginDeclaration`] with metadata
119/// - [`CREATE_SYMBOL`]: a [`CreateFn`] that constructs the plugin
120///
121/// The loader reads the declaration first to check API/protocol compatibility,
122/// then calls the create function to obtain a `Box<dyn Plugin>`.
123pub mod abi {
124    use super::Plugin;
125
126    /// Null-terminated symbol name for the [`PluginDeclaration`] static.
127    pub const DECLARATION_SYMBOL: &[u8] = b"ROOM_PLUGIN_DECLARATION\0";
128
129    /// Null-terminated symbol name for the [`CreateFn`] function.
130    pub const CREATE_SYMBOL: &[u8] = b"room_plugin_create\0";
131
132    /// Null-terminated symbol name for the [`DestroyFn`] function.
133    pub const DESTROY_SYMBOL: &[u8] = b"room_plugin_destroy\0";
134
135    /// C-compatible plugin metadata exported as a `#[no_mangle]` static from
136    /// each cdylib plugin.
137    ///
138    /// The loader reads this before calling [`CreateFn`] to verify that the
139    /// plugin's API version and protocol requirements are compatible with the
140    /// running broker.
141    ///
142    /// Use [`PluginDeclaration::new`] to construct in a `static` context.
143    #[repr(C)]
144    pub struct PluginDeclaration {
145        /// Must equal [`super::PLUGIN_API_VERSION`] for the plugin to load.
146        pub api_version: u32,
147        /// Pointer to the plugin name string (UTF-8, not necessarily null-terminated).
148        pub name_ptr: *const u8,
149        /// Length of the plugin name string in bytes.
150        pub name_len: usize,
151        /// Pointer to the plugin version string (semver, UTF-8).
152        pub version_ptr: *const u8,
153        /// Length of the plugin version string in bytes.
154        pub version_len: usize,
155        /// Pointer to the minimum room-protocol version string (semver, UTF-8).
156        pub min_protocol_ptr: *const u8,
157        /// Length of the minimum protocol version string in bytes.
158        pub min_protocol_len: usize,
159    }
160
161    // SAFETY: PluginDeclaration contains only raw pointers to static data and
162    // plain integers — no interior mutability, no heap allocation. The pointed-to
163    // data lives for `'static` (string literals or env!() constants).
164    unsafe impl Send for PluginDeclaration {}
165    unsafe impl Sync for PluginDeclaration {}
166
167    impl PluginDeclaration {
168        /// Construct a declaration from static string slices. All arguments must
169        /// be `'static` — this is enforced by the function signature and is
170        /// required because the declaration is stored as a `static`.
171        pub const fn new(
172            api_version: u32,
173            name: &'static str,
174            version: &'static str,
175            min_protocol: &'static str,
176        ) -> Self {
177            Self {
178                api_version,
179                name_ptr: name.as_ptr(),
180                name_len: name.len(),
181                version_ptr: version.as_ptr(),
182                version_len: version.len(),
183                min_protocol_ptr: min_protocol.as_ptr(),
184                min_protocol_len: min_protocol.len(),
185            }
186        }
187
188        /// Reconstruct the plugin name.
189        ///
190        /// Returns `Err` if the bytes are not valid UTF-8.
191        ///
192        /// # Safety
193        ///
194        /// The declaration must still be valid — i.e. the shared library that
195        /// exported it must not have been unloaded, and the pointer/length pair
196        /// must point to a valid byte slice.
197        pub unsafe fn name(&self) -> Result<&str, core::str::Utf8Error> {
198            core::str::from_utf8(core::slice::from_raw_parts(self.name_ptr, self.name_len))
199        }
200
201        /// Reconstruct the plugin version string.
202        ///
203        /// Returns `Err` if the bytes are not valid UTF-8.
204        ///
205        /// # Safety
206        ///
207        /// Same as [`name`](Self::name).
208        pub unsafe fn version(&self) -> Result<&str, core::str::Utf8Error> {
209            core::str::from_utf8(core::slice::from_raw_parts(
210                self.version_ptr,
211                self.version_len,
212            ))
213        }
214
215        /// Reconstruct the minimum protocol version string.
216        ///
217        /// Returns `Err` if the bytes are not valid UTF-8.
218        ///
219        /// # Safety
220        ///
221        /// Same as [`name`](Self::name).
222        pub unsafe fn min_protocol(&self) -> Result<&str, core::str::Utf8Error> {
223            core::str::from_utf8(core::slice::from_raw_parts(
224                self.min_protocol_ptr,
225                self.min_protocol_len,
226            ))
227        }
228    }
229
230    /// Type signature for the plugin creation function exported by cdylib plugins.
231    ///
232    /// The function receives a UTF-8 JSON configuration string (pointer + length)
233    /// and returns a double-boxed `Plugin` trait object. The outer `Box` yields a
234    /// thin pointer (C-ABI safe); the inner `Box<dyn Plugin>` is a fat pointer
235    /// stored on the heap.
236    ///
237    /// # Arguments
238    ///
239    /// * `config_json` — pointer to a UTF-8 JSON string, or null for default config
240    /// * `config_len` — length of the config string in bytes (0 if null)
241    ///
242    /// # Returns
243    ///
244    /// A thin pointer to a heap-allocated `Box<dyn Plugin>`. The caller takes
245    /// ownership and must free it via [`DestroyFn`] or
246    /// `drop(Box::from_raw(ptr))`.
247    ///
248    /// # Safety
249    ///
250    /// * If `config_json` is non-null, it must be valid for reads of `config_len` bytes
251    /// * The returned pointer must not be null
252    pub type CreateFn =
253        unsafe extern "C" fn(config_json: *const u8, config_len: usize) -> *mut Box<dyn Plugin>;
254
255    /// Type signature for the plugin destruction function exported by cdylib plugins.
256    ///
257    /// Frees a plugin previously returned by [`CreateFn`]. The loader calls this
258    /// during shutdown or when unloading a plugin.
259    ///
260    /// # Safety
261    ///
262    /// * `plugin` must have been returned by [`CreateFn`] from the same library
263    /// * Must not be called more than once on the same pointer
264    pub type DestroyFn = unsafe extern "C" fn(plugin: *mut Box<dyn Plugin>);
265
266    /// Helper to extract a `&str` config from raw FFI pointers.
267    ///
268    /// Returns an empty string if the pointer is null or the length is zero.
269    /// Panics if the bytes are not valid UTF-8.
270    ///
271    /// # Safety
272    ///
273    /// If `ptr` is non-null, it must be valid for reads of `len` bytes.
274    pub unsafe fn config_from_raw(ptr: *const u8, len: usize) -> &'static str {
275        if ptr.is_null() || len == 0 {
276            ""
277        } else {
278            let bytes = core::slice::from_raw_parts(ptr, len);
279            core::str::from_utf8(bytes).expect("plugin config is not valid UTF-8")
280        }
281    }
282}
283
284/// Declares the C ABI entry points for a cdylib plugin.
285///
286/// Generates three `#[no_mangle]` exports:
287/// - `ROOM_PLUGIN_DECLARATION` — a [`abi::PluginDeclaration`] static
288/// - `room_plugin_create` — calls the provided closure with a `&str` config
289///   and returns a double-boxed `dyn Plugin`
290/// - `room_plugin_destroy` — frees a plugin returned by `room_plugin_create`
291///
292/// # Arguments
293///
294/// * `$name` — plugin name as a string literal (e.g. `"taskboard"`)
295/// * `$create` — an expression that takes `config: &str` and returns
296///   `impl Plugin` (e.g. a closure or function call)
297///
298/// # Example
299///
300/// ```ignore
301/// use room_protocol::declare_plugin;
302///
303/// declare_plugin!("my-plugin", |config: &str| {
304///     MyPlugin::from_config(config)
305/// });
306/// ```
307#[macro_export]
308macro_rules! declare_plugin {
309    ($name:expr, $create:expr) => {
310        /// Plugin metadata for dynamic loading.
311        ///
312        /// When the `cdylib-exports` feature is enabled, this static is exported
313        /// with `#[no_mangle]` so that `libloading` can find it by name. When
314        /// the feature is off (rlib / static linking), the symbol is mangled to
315        /// avoid collisions with other plugins in the same binary.
316        #[cfg_attr(feature = "cdylib-exports", no_mangle)]
317        pub static ROOM_PLUGIN_DECLARATION: $crate::plugin::abi::PluginDeclaration =
318            $crate::plugin::abi::PluginDeclaration::new(
319                $crate::plugin::PLUGIN_API_VERSION,
320                $name,
321                env!("CARGO_PKG_VERSION"),
322                "0.0.0",
323            );
324
325        /// # Safety
326        ///
327        /// See [`room_protocol::plugin::abi::CreateFn`] for safety contract.
328        #[cfg_attr(feature = "cdylib-exports", no_mangle)]
329        pub unsafe extern "C" fn room_plugin_create(
330            config_json: *const u8,
331            config_len: usize,
332        ) -> *mut Box<dyn $crate::plugin::Plugin> {
333            let config = unsafe { $crate::plugin::abi::config_from_raw(config_json, config_len) };
334            let create_fn = $create;
335            let plugin: Box<dyn $crate::plugin::Plugin> = Box::new(create_fn(config));
336            Box::into_raw(Box::new(plugin))
337        }
338
339        /// # Safety
340        ///
341        /// See [`room_protocol::plugin::abi::DestroyFn`] for safety contract.
342        #[cfg_attr(feature = "cdylib-exports", no_mangle)]
343        pub unsafe extern "C" fn room_plugin_destroy(plugin: *mut Box<dyn $crate::plugin::Plugin>) {
344            if !plugin.is_null() {
345                drop(unsafe { Box::from_raw(plugin) });
346            }
347        }
348    };
349}
350
351// ── CommandInfo ─────────────────────────────────────────────────────────────
352
353/// Describes a single command for `/help` and autocomplete.
354#[derive(Debug, Clone)]
355pub struct CommandInfo {
356    /// Command name without the leading `/`.
357    pub name: String,
358    /// One-line description shown in `/help` and autocomplete.
359    pub description: String,
360    /// Usage string (e.g. `"/stats [last N]"`).
361    pub usage: String,
362    /// Typed parameter schemas for validation and autocomplete.
363    pub params: Vec<ParamSchema>,
364}
365
366// ── Typed parameter schema ─────────────────────────────────────────────────
367
368/// Schema for a single command parameter — drives validation, `/help` output,
369/// and TUI argument autocomplete.
370#[derive(Debug, Clone)]
371pub struct ParamSchema {
372    /// Display name (e.g. `"username"`, `"count"`).
373    pub name: String,
374    /// What kind of value this parameter accepts.
375    pub param_type: ParamType,
376    /// Whether the parameter must be provided.
377    pub required: bool,
378    /// One-line description shown in `/help <command>`.
379    pub description: String,
380}
381
382/// The kind of value a parameter accepts.
383#[derive(Debug, Clone, PartialEq)]
384pub enum ParamType {
385    /// Free-form text (no validation beyond presence).
386    Text,
387    /// One of a fixed set of allowed values.
388    Choice(Vec<String>),
389    /// An online username — TUI shows the mention picker.
390    Username,
391    /// An integer, optionally bounded.
392    Number { min: Option<i64>, max: Option<i64> },
393}
394
395// ── CommandContext ───────────────────────────────────────────────────────────
396
397/// Context passed to a plugin's `handle` method.
398pub struct CommandContext {
399    /// The command name that was invoked (without `/`).
400    pub command: String,
401    /// Arguments passed after the command name.
402    pub params: Vec<String>,
403    /// Username of the invoker.
404    pub sender: String,
405    /// Room ID.
406    pub room_id: String,
407    /// Message ID that triggered this command.
408    pub message_id: String,
409    /// Timestamp of the triggering message.
410    pub timestamp: DateTime<Utc>,
411    /// Scoped handle for reading chat history.
412    pub history: Box<dyn HistoryAccess>,
413    /// Scoped handle for writing back to the chat.
414    pub writer: Box<dyn MessageWriter>,
415    /// Snapshot of room metadata.
416    pub metadata: RoomMetadata,
417    /// All registered commands (so `/help` can list them without
418    /// holding a reference to the registry).
419    pub available_commands: Vec<CommandInfo>,
420    /// Optional access to daemon-level team membership.
421    ///
422    /// `Some` in daemon mode (backed by `UserRegistry`), `None` in standalone
423    /// mode where teams are not available.
424    pub team_access: Option<Box<dyn TeamAccess>>,
425}
426
427// ── PluginResult ────────────────────────────────────────────────────────────
428
429/// What the broker should do after a plugin handles a command.
430pub enum PluginResult {
431    /// Send a private reply only to the invoker.
432    /// Second element is optional machine-readable data for programmatic consumers.
433    Reply(String, Option<serde_json::Value>),
434    /// Broadcast a message to the entire room.
435    /// Second element is optional machine-readable data for programmatic consumers.
436    Broadcast(String, Option<serde_json::Value>),
437    /// Command handled silently (side effects already done via [`MessageWriter`]).
438    Handled,
439}
440
441// ── MessageWriter trait ─────────────────────────────────────────────────────
442
443/// Async message dispatch for plugins. Abstracts over the broker's broadcast
444/// and persistence machinery so plugins never touch broker internals.
445///
446/// The broker provides a concrete implementation; external crates only see
447/// this trait.
448pub trait MessageWriter: Send + Sync {
449    /// Broadcast a system message to all connected clients and persist to history.
450    fn broadcast(&self, content: &str) -> BoxFuture<'_, anyhow::Result<()>>;
451
452    /// Send a private system message only to a specific user.
453    fn reply_to(&self, username: &str, content: &str) -> BoxFuture<'_, anyhow::Result<()>>;
454
455    /// Broadcast a typed event to all connected clients and persist to history.
456    fn emit_event(
457        &self,
458        event_type: EventType,
459        content: &str,
460        params: Option<serde_json::Value>,
461    ) -> BoxFuture<'_, anyhow::Result<()>>;
462}
463
464// ── HistoryAccess trait ─────────────────────────────────────────────────────
465
466/// Async read-only access to a room's chat history.
467///
468/// Respects DM visibility — a plugin invoked by user X will not see DMs
469/// between Y and Z.
470pub trait HistoryAccess: Send + Sync {
471    /// Load all messages (filtered by DM visibility).
472    fn all(&self) -> BoxFuture<'_, anyhow::Result<Vec<Message>>>;
473
474    /// Load the last `n` messages (filtered by DM visibility).
475    fn tail(&self, n: usize) -> BoxFuture<'_, anyhow::Result<Vec<Message>>>;
476
477    /// Load messages after the one with the given ID (filtered by DM visibility).
478    fn since(&self, message_id: &str) -> BoxFuture<'_, anyhow::Result<Vec<Message>>>;
479
480    /// Count total messages in the chat.
481    fn count(&self) -> BoxFuture<'_, anyhow::Result<usize>>;
482}
483
484// ── TeamAccess trait ────────────────────────────────────────────────────────
485
486/// Read-only access to daemon-level team membership.
487///
488/// Plugins use this trait to check whether a user belongs to a team without
489/// depending on `room-daemon` or `UserRegistry` directly. The broker provides
490/// a concrete implementation backed by the registry; standalone mode passes
491/// `None` (no team checking available).
492pub trait TeamAccess: Send + Sync {
493    /// Returns `true` if the named team exists in the registry.
494    fn team_exists(&self, team: &str) -> bool;
495
496    /// Returns `true` if `user` is a member of `team`.
497    fn is_member(&self, team: &str, user: &str) -> bool;
498}
499
500// ── RoomMetadata ────────────────────────────────────────────────────────────
501
502/// Frozen snapshot of room state for plugin consumption.
503pub struct RoomMetadata {
504    /// Users currently online with their status.
505    pub online_users: Vec<UserInfo>,
506    /// Username of the room host.
507    pub host: Option<String>,
508    /// Total messages in the chat file.
509    pub message_count: usize,
510}
511
512/// A user's online presence.
513pub struct UserInfo {
514    pub username: String,
515    pub status: String,
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    #[test]
523    fn param_type_choice_equality() {
524        let a = ParamType::Choice(vec!["x".to_owned(), "y".to_owned()]);
525        let b = ParamType::Choice(vec!["x".to_owned(), "y".to_owned()]);
526        assert_eq!(a, b);
527        let c = ParamType::Choice(vec!["x".to_owned()]);
528        assert_ne!(a, c);
529    }
530
531    #[test]
532    fn param_type_number_equality() {
533        let a = ParamType::Number {
534            min: Some(1),
535            max: Some(100),
536        };
537        let b = ParamType::Number {
538            min: Some(1),
539            max: Some(100),
540        };
541        assert_eq!(a, b);
542        let c = ParamType::Number {
543            min: None,
544            max: None,
545        };
546        assert_ne!(a, c);
547    }
548
549    #[test]
550    fn param_type_variants_are_distinct() {
551        assert_ne!(ParamType::Text, ParamType::Username);
552        assert_ne!(
553            ParamType::Text,
554            ParamType::Number {
555                min: None,
556                max: None
557            }
558        );
559        assert_ne!(ParamType::Text, ParamType::Choice(vec!["a".to_owned()]));
560    }
561
562    // ── Versioning defaults ─────────────────────────────────────────────
563
564    struct DefaultsPlugin;
565
566    impl Plugin for DefaultsPlugin {
567        fn name(&self) -> &str {
568            "defaults"
569        }
570
571        fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
572            Box::pin(async { Ok(PluginResult::Handled) })
573        }
574    }
575
576    #[test]
577    fn default_version_is_zero() {
578        assert_eq!(DefaultsPlugin.version(), "0.0.0");
579    }
580
581    #[test]
582    fn default_api_version_is_one() {
583        assert_eq!(DefaultsPlugin.api_version(), 1);
584    }
585
586    #[test]
587    fn default_min_protocol_is_zero() {
588        assert_eq!(DefaultsPlugin.min_protocol(), "0.0.0");
589    }
590
591    #[test]
592    fn plugin_api_version_const_is_one() {
593        assert_eq!(PLUGIN_API_VERSION, 1);
594    }
595
596    #[test]
597    fn protocol_version_const_matches_cargo() {
598        // PROTOCOL_VERSION is set at compile time via env!("CARGO_PKG_VERSION").
599        // It must be a non-empty semver string with at least major.minor.patch.
600        assert!(!PROTOCOL_VERSION.is_empty());
601        let parts: Vec<&str> = PROTOCOL_VERSION.split('.').collect();
602        assert!(
603            parts.len() >= 3,
604            "PROTOCOL_VERSION must be major.minor.patch, got: {PROTOCOL_VERSION}"
605        );
606        for part in &parts {
607            assert!(
608                part.parse::<u64>().is_ok(),
609                "each segment must be numeric, got: {part}"
610            );
611        }
612    }
613
614    struct VersionedPlugin;
615
616    impl Plugin for VersionedPlugin {
617        fn name(&self) -> &str {
618            "versioned"
619        }
620
621        fn version(&self) -> &str {
622            "2.5.1"
623        }
624
625        fn api_version(&self) -> u32 {
626            1
627        }
628
629        fn min_protocol(&self) -> &str {
630            "3.0.0"
631        }
632
633        fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
634            Box::pin(async { Ok(PluginResult::Handled) })
635        }
636    }
637
638    #[test]
639    fn custom_version_methods_override_defaults() {
640        assert_eq!(VersionedPlugin.version(), "2.5.1");
641        assert_eq!(VersionedPlugin.api_version(), 1);
642        assert_eq!(VersionedPlugin.min_protocol(), "3.0.0");
643    }
644
645    // ── ABI types ──────────────────────────────────────────────────────────
646
647    #[test]
648    fn declaration_new_stores_correct_values() {
649        let decl = abi::PluginDeclaration::new(1, "test-plugin", "1.2.3", "3.0.0");
650        assert_eq!(decl.api_version, 1);
651        unsafe {
652            assert_eq!(decl.name().unwrap(), "test-plugin");
653            assert_eq!(decl.version().unwrap(), "1.2.3");
654            assert_eq!(decl.min_protocol().unwrap(), "3.0.0");
655        }
656    }
657
658    #[test]
659    fn declaration_with_empty_strings() {
660        let decl = abi::PluginDeclaration::new(0, "", "", "");
661        assert_eq!(decl.api_version, 0);
662        assert_eq!(decl.name_len, 0);
663        assert_eq!(decl.version_len, 0);
664        assert_eq!(decl.min_protocol_len, 0);
665    }
666
667    #[test]
668    fn declaration_is_repr_c_sized() {
669        // PluginDeclaration must have a stable, known size for FFI.
670        // On 64-bit: u32(4) + padding(4) + 3*(ptr+usize) = 4+4+48 = 56 bytes
671        let size = std::mem::size_of::<abi::PluginDeclaration>();
672        assert!(size > 0, "PluginDeclaration must have non-zero size");
673        // Alignment must be pointer-aligned for C compatibility.
674        let align = std::mem::align_of::<abi::PluginDeclaration>();
675        assert!(
676            align >= std::mem::align_of::<usize>(),
677            "PluginDeclaration must be at least pointer-aligned"
678        );
679    }
680
681    #[test]
682    fn config_from_raw_null_returns_empty() {
683        let result = unsafe { abi::config_from_raw(std::ptr::null(), 0) };
684        assert_eq!(result, "");
685    }
686
687    #[test]
688    fn config_from_raw_zero_len_returns_empty() {
689        let data = b"some data";
690        let result = unsafe { abi::config_from_raw(data.as_ptr(), 0) };
691        assert_eq!(result, "");
692    }
693
694    #[test]
695    fn config_from_raw_valid_data() {
696        let json = b"{\"path\":\"/tmp\"}";
697        let result = unsafe { abi::config_from_raw(json.as_ptr(), json.len()) };
698        assert_eq!(result, "{\"path\":\"/tmp\"}");
699    }
700
701    #[test]
702    fn symbol_names_are_null_terminated() {
703        assert!(abi::DECLARATION_SYMBOL.ends_with(b"\0"));
704        assert!(abi::CREATE_SYMBOL.ends_with(b"\0"));
705        assert!(abi::DESTROY_SYMBOL.ends_with(b"\0"));
706    }
707
708    #[test]
709    fn create_fn_type_is_c_abi() {
710        // Verify CreateFn can be stored in a function pointer variable.
711        // This is a compile-time check — if the type is invalid, it won't compile.
712        let _: Option<abi::CreateFn> = None;
713        let _: Option<abi::DestroyFn> = None;
714    }
715}