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}