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    /// Sub-command schemas for multi-action commands (e.g. `/taskboard plan`).
365    /// Empty for simple commands. Used by `/help <cmd> <sub>` to show
366    /// per-subcommand help.
367    pub subcommands: Vec<CommandInfo>,
368}
369
370// ── Typed parameter schema ─────────────────────────────────────────────────
371
372/// Schema for a single command parameter — drives validation, `/help` output,
373/// and TUI argument autocomplete.
374#[derive(Debug, Clone)]
375pub struct ParamSchema {
376    /// Display name (e.g. `"username"`, `"count"`).
377    pub name: String,
378    /// What kind of value this parameter accepts.
379    pub param_type: ParamType,
380    /// Whether the parameter must be provided.
381    pub required: bool,
382    /// One-line description shown in `/help <command>`.
383    pub description: String,
384}
385
386/// The kind of value a parameter accepts.
387#[derive(Debug, Clone, PartialEq)]
388pub enum ParamType {
389    /// Free-form text (no validation beyond presence).
390    Text,
391    /// One of a fixed set of allowed values.
392    Choice(Vec<String>),
393    /// An online username — TUI shows the mention picker.
394    Username,
395    /// An integer, optionally bounded.
396    Number { min: Option<i64>, max: Option<i64> },
397}
398
399// ── CommandContext ───────────────────────────────────────────────────────────
400
401/// Context passed to a plugin's `handle` method.
402pub struct CommandContext {
403    /// The command name that was invoked (without `/`).
404    pub command: String,
405    /// Arguments passed after the command name.
406    pub params: Vec<String>,
407    /// Username of the invoker.
408    pub sender: String,
409    /// Room ID.
410    pub room_id: String,
411    /// Message ID that triggered this command.
412    pub message_id: String,
413    /// Timestamp of the triggering message.
414    pub timestamp: DateTime<Utc>,
415    /// Scoped handle for reading chat history.
416    pub history: Box<dyn HistoryAccess>,
417    /// Scoped handle for writing back to the chat.
418    pub writer: Box<dyn MessageWriter>,
419    /// Snapshot of room metadata.
420    pub metadata: RoomMetadata,
421    /// All registered commands (so `/help` can list them without
422    /// holding a reference to the registry).
423    pub available_commands: Vec<CommandInfo>,
424    /// Optional access to daemon-level team membership.
425    ///
426    /// `Some` in daemon mode (backed by `UserRegistry`), `None` in standalone
427    /// mode where teams are not available.
428    pub team_access: Option<Box<dyn TeamAccess>>,
429}
430
431// ── PluginResult ────────────────────────────────────────────────────────────
432
433/// What the broker should do after a plugin handles a command.
434pub enum PluginResult {
435    /// Send a private reply only to the invoker.
436    /// Second element is optional machine-readable data for programmatic consumers.
437    Reply(String, Option<serde_json::Value>),
438    /// Broadcast a message to the entire room.
439    /// Second element is optional machine-readable data for programmatic consumers.
440    Broadcast(String, Option<serde_json::Value>),
441    /// Command handled silently (side effects already done via [`MessageWriter`]).
442    Handled,
443}
444
445// ── MessageWriter trait ─────────────────────────────────────────────────────
446
447/// Async message dispatch for plugins. Abstracts over the broker's broadcast
448/// and persistence machinery so plugins never touch broker internals.
449///
450/// The broker provides a concrete implementation; external crates only see
451/// this trait.
452pub trait MessageWriter: Send + Sync {
453    /// Broadcast a system message to all connected clients and persist to history.
454    fn broadcast(&self, content: &str) -> BoxFuture<'_, anyhow::Result<()>>;
455
456    /// Send a private system message only to a specific user.
457    fn reply_to(&self, username: &str, content: &str) -> BoxFuture<'_, anyhow::Result<()>>;
458
459    /// Broadcast a typed event to all connected clients and persist to history.
460    fn emit_event(
461        &self,
462        event_type: EventType,
463        content: &str,
464        params: Option<serde_json::Value>,
465    ) -> BoxFuture<'_, anyhow::Result<()>>;
466}
467
468// ── HistoryAccess trait ─────────────────────────────────────────────────────
469
470/// Async read-only access to a room's chat history.
471///
472/// Respects DM visibility — a plugin invoked by user X will not see DMs
473/// between Y and Z.
474pub trait HistoryAccess: Send + Sync {
475    /// Load all messages (filtered by DM visibility).
476    fn all(&self) -> BoxFuture<'_, anyhow::Result<Vec<Message>>>;
477
478    /// Load the last `n` messages (filtered by DM visibility).
479    fn tail(&self, n: usize) -> BoxFuture<'_, anyhow::Result<Vec<Message>>>;
480
481    /// Load messages after the one with the given ID (filtered by DM visibility).
482    fn since(&self, message_id: &str) -> BoxFuture<'_, anyhow::Result<Vec<Message>>>;
483
484    /// Count total messages in the chat.
485    fn count(&self) -> BoxFuture<'_, anyhow::Result<usize>>;
486}
487
488// ── TeamAccess trait ────────────────────────────────────────────────────────
489
490/// Read-only access to daemon-level team membership.
491///
492/// Plugins use this trait to check whether a user belongs to a team without
493/// depending on `room-daemon` or `UserRegistry` directly. The broker provides
494/// a concrete implementation backed by the registry; standalone mode passes
495/// `None` (no team checking available).
496pub trait TeamAccess: Send + Sync {
497    /// Returns `true` if the named team exists in the registry.
498    fn team_exists(&self, team: &str) -> bool;
499
500    /// Returns `true` if `user` is a member of `team`.
501    fn is_member(&self, team: &str, user: &str) -> bool;
502}
503
504// ── RoomMetadata ────────────────────────────────────────────────────────────
505
506/// Frozen snapshot of room state for plugin consumption.
507pub struct RoomMetadata {
508    /// Users currently online with their status.
509    pub online_users: Vec<UserInfo>,
510    /// Username of the room host.
511    pub host: Option<String>,
512    /// Total messages in the chat file.
513    pub message_count: usize,
514}
515
516/// A user's online presence.
517pub struct UserInfo {
518    pub username: String,
519    pub status: String,
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[test]
527    fn param_type_choice_equality() {
528        let a = ParamType::Choice(vec!["x".to_owned(), "y".to_owned()]);
529        let b = ParamType::Choice(vec!["x".to_owned(), "y".to_owned()]);
530        assert_eq!(a, b);
531        let c = ParamType::Choice(vec!["x".to_owned()]);
532        assert_ne!(a, c);
533    }
534
535    #[test]
536    fn param_type_number_equality() {
537        let a = ParamType::Number {
538            min: Some(1),
539            max: Some(100),
540        };
541        let b = ParamType::Number {
542            min: Some(1),
543            max: Some(100),
544        };
545        assert_eq!(a, b);
546        let c = ParamType::Number {
547            min: None,
548            max: None,
549        };
550        assert_ne!(a, c);
551    }
552
553    #[test]
554    fn param_type_variants_are_distinct() {
555        assert_ne!(ParamType::Text, ParamType::Username);
556        assert_ne!(
557            ParamType::Text,
558            ParamType::Number {
559                min: None,
560                max: None
561            }
562        );
563        assert_ne!(ParamType::Text, ParamType::Choice(vec!["a".to_owned()]));
564    }
565
566    // ── Versioning defaults ─────────────────────────────────────────────
567
568    struct DefaultsPlugin;
569
570    impl Plugin for DefaultsPlugin {
571        fn name(&self) -> &str {
572            "defaults"
573        }
574
575        fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
576            Box::pin(async { Ok(PluginResult::Handled) })
577        }
578    }
579
580    #[test]
581    fn default_version_is_zero() {
582        assert_eq!(DefaultsPlugin.version(), "0.0.0");
583    }
584
585    #[test]
586    fn default_api_version_is_one() {
587        assert_eq!(DefaultsPlugin.api_version(), 1);
588    }
589
590    #[test]
591    fn default_min_protocol_is_zero() {
592        assert_eq!(DefaultsPlugin.min_protocol(), "0.0.0");
593    }
594
595    #[test]
596    fn plugin_api_version_const_is_one() {
597        assert_eq!(PLUGIN_API_VERSION, 1);
598    }
599
600    #[test]
601    fn protocol_version_const_matches_cargo() {
602        // PROTOCOL_VERSION is set at compile time via env!("CARGO_PKG_VERSION").
603        // It must be a non-empty semver string with at least major.minor.patch.
604        assert!(!PROTOCOL_VERSION.is_empty());
605        let parts: Vec<&str> = PROTOCOL_VERSION.split('.').collect();
606        assert!(
607            parts.len() >= 3,
608            "PROTOCOL_VERSION must be major.minor.patch, got: {PROTOCOL_VERSION}"
609        );
610        for part in &parts {
611            assert!(
612                part.parse::<u64>().is_ok(),
613                "each segment must be numeric, got: {part}"
614            );
615        }
616    }
617
618    struct VersionedPlugin;
619
620    impl Plugin for VersionedPlugin {
621        fn name(&self) -> &str {
622            "versioned"
623        }
624
625        fn version(&self) -> &str {
626            "2.5.1"
627        }
628
629        fn api_version(&self) -> u32 {
630            1
631        }
632
633        fn min_protocol(&self) -> &str {
634            "3.0.0"
635        }
636
637        fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
638            Box::pin(async { Ok(PluginResult::Handled) })
639        }
640    }
641
642    #[test]
643    fn custom_version_methods_override_defaults() {
644        assert_eq!(VersionedPlugin.version(), "2.5.1");
645        assert_eq!(VersionedPlugin.api_version(), 1);
646        assert_eq!(VersionedPlugin.min_protocol(), "3.0.0");
647    }
648
649    // ── ABI types ──────────────────────────────────────────────────────────
650
651    #[test]
652    fn declaration_new_stores_correct_values() {
653        let decl = abi::PluginDeclaration::new(1, "test-plugin", "1.2.3", "3.0.0");
654        assert_eq!(decl.api_version, 1);
655        unsafe {
656            assert_eq!(decl.name().unwrap(), "test-plugin");
657            assert_eq!(decl.version().unwrap(), "1.2.3");
658            assert_eq!(decl.min_protocol().unwrap(), "3.0.0");
659        }
660    }
661
662    #[test]
663    fn declaration_with_empty_strings() {
664        let decl = abi::PluginDeclaration::new(0, "", "", "");
665        assert_eq!(decl.api_version, 0);
666        assert_eq!(decl.name_len, 0);
667        assert_eq!(decl.version_len, 0);
668        assert_eq!(decl.min_protocol_len, 0);
669    }
670
671    #[test]
672    fn declaration_is_repr_c_sized() {
673        // PluginDeclaration must have a stable, known size for FFI.
674        // On 64-bit: u32(4) + padding(4) + 3*(ptr+usize) = 4+4+48 = 56 bytes
675        let size = std::mem::size_of::<abi::PluginDeclaration>();
676        assert!(size > 0, "PluginDeclaration must have non-zero size");
677        // Alignment must be pointer-aligned for C compatibility.
678        let align = std::mem::align_of::<abi::PluginDeclaration>();
679        assert!(
680            align >= std::mem::align_of::<usize>(),
681            "PluginDeclaration must be at least pointer-aligned"
682        );
683    }
684
685    #[test]
686    fn config_from_raw_null_returns_empty() {
687        let result = unsafe { abi::config_from_raw(std::ptr::null(), 0) };
688        assert_eq!(result, "");
689    }
690
691    #[test]
692    fn config_from_raw_zero_len_returns_empty() {
693        let data = b"some data";
694        let result = unsafe { abi::config_from_raw(data.as_ptr(), 0) };
695        assert_eq!(result, "");
696    }
697
698    #[test]
699    fn config_from_raw_valid_data() {
700        let json = b"{\"path\":\"/tmp\"}";
701        let result = unsafe { abi::config_from_raw(json.as_ptr(), json.len()) };
702        assert_eq!(result, "{\"path\":\"/tmp\"}");
703    }
704
705    #[test]
706    fn symbol_names_are_null_terminated() {
707        assert!(abi::DECLARATION_SYMBOL.ends_with(b"\0"));
708        assert!(abi::CREATE_SYMBOL.ends_with(b"\0"));
709        assert!(abi::DESTROY_SYMBOL.ends_with(b"\0"));
710    }
711
712    #[test]
713    fn create_fn_type_is_c_abi() {
714        // Verify CreateFn can be stored in a function pointer variable.
715        // This is a compile-time check — if the type is invalid, it won't compile.
716        let _: Option<abi::CreateFn> = None;
717        let _: Option<abi::DestroyFn> = None;
718    }
719}