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}