Skip to main content

room_daemon/plugin/
mod.rs

1pub mod bridge;
2pub mod loader;
3pub mod queue;
4pub mod schema;
5pub mod stats;
6
7/// Re-export the taskboard plugin from its own crate.
8pub use room_plugin_taskboard as taskboard;
9
10use std::{collections::HashMap, path::Path};
11
12// Re-export all plugin framework types from room-protocol so that existing
13// imports from `crate::plugin::*` continue to work without changes.
14pub use room_protocol::plugin::{
15    BoxFuture, CommandContext, CommandInfo, HistoryAccess, MessageWriter, ParamSchema, ParamType,
16    Plugin, PluginResult, RoomMetadata, TeamAccess, UserInfo, PLUGIN_API_VERSION, PROTOCOL_VERSION,
17};
18
19// Re-export concrete bridge types. ChatWriter and HistoryReader are public
20// (used in tests and by broker/commands.rs). snapshot_metadata is crate-only.
21pub(crate) use bridge::snapshot_metadata;
22pub use bridge::{ChatWriter, HistoryReader, TeamChecker};
23pub use schema::{all_known_commands, builtin_command_infos};
24
25// ── PluginRegistry ──────────────────────────────────────────────────────────
26
27/// Built-in command names that plugins may not override.
28const RESERVED_COMMANDS: &[&str] = &[
29    "who",
30    "help",
31    "info",
32    "kick",
33    "reauth",
34    "clear-tokens",
35    "dm",
36    "reply",
37    "room-info",
38    "exit",
39    "clear",
40    "subscribe",
41    "set_subscription",
42    "unsubscribe",
43    "subscribe_events",
44    "set_event_filter",
45    "set_status",
46    "subscriptions",
47    "team",
48];
49
50/// Central registry of plugins. The broker uses this to dispatch `/` commands.
51pub struct PluginRegistry {
52    plugins: Vec<Box<dyn Plugin>>,
53    /// command_name → index into `plugins`.
54    command_map: HashMap<String, usize>,
55}
56
57impl PluginRegistry {
58    pub fn new() -> Self {
59        Self {
60            plugins: Vec::new(),
61            command_map: HashMap::new(),
62        }
63    }
64
65    /// Create a registry with all standard plugins registered.
66    ///
67    /// Both standalone and daemon broker modes should call this so that every
68    /// room has the same set of `/` commands available.
69    pub(crate) fn with_all_plugins(chat_path: &Path) -> anyhow::Result<Self> {
70        let mut registry = Self::new();
71
72        let queue_path = queue::QueuePlugin::queue_path_from_chat(chat_path);
73        registry.register(Box::new(queue::QueuePlugin::new(queue_path)?))?;
74
75        registry.register(Box::new(stats::StatsPlugin))?;
76
77        let taskboard_path = taskboard::TaskboardPlugin::taskboard_path_from_chat(chat_path);
78        registry.register(Box::new(taskboard::TaskboardPlugin::new(
79            taskboard_path,
80            None,
81        )))?;
82
83        // Agent plugin is no longer statically linked — it lives in the
84        // knoxio/room-ralph repo and is loaded dynamically from ~/.room/plugins/.
85
86        // Load external plugins from ~/.room/plugins/
87        let plugins_dir = crate::paths::room_plugins_dir();
88        for loaded in loader::scan_plugin_dir(&plugins_dir) {
89            let name = loaded.plugin().name().to_owned();
90            // SAFETY: the plugin is registered into the registry which lives
91            // for the broker's lifetime. The library handle is leaked (not
92            // unloaded) to keep vtable pointers valid.
93            let plugin = unsafe { loaded.into_boxed_plugin() };
94            if let Err(e) = registry.register(plugin) {
95                eprintln!(
96                    "[plugin] external plugin '{}' registration failed: {e}",
97                    name
98                );
99            }
100        }
101
102        Ok(registry)
103    }
104
105    /// Register a plugin. Returns an error if:
106    /// - any command name collides with a built-in or another plugin's command
107    /// - `api_version()` exceeds the current [`PLUGIN_API_VERSION`]
108    /// - `min_protocol()` is newer than the running [`PROTOCOL_VERSION`]
109    pub fn register(&mut self, plugin: Box<dyn Plugin>) -> anyhow::Result<()> {
110        // ── Version compatibility checks ────────────────────────────────
111        let api_v = plugin.api_version();
112        if api_v > PLUGIN_API_VERSION {
113            anyhow::bail!(
114                "plugin '{}' requires api_version {api_v} but broker supports up to {PLUGIN_API_VERSION}",
115                plugin.name(),
116            );
117        }
118
119        let min_proto = plugin.min_protocol();
120        if !semver_satisfies(PROTOCOL_VERSION, min_proto) {
121            anyhow::bail!(
122                "plugin '{}' requires room-protocol >= {min_proto} but broker has {PROTOCOL_VERSION}",
123                plugin.name(),
124            );
125        }
126
127        // ── Command name uniqueness checks ──────────────────────────────
128        let idx = self.plugins.len();
129        for cmd in plugin.commands() {
130            if RESERVED_COMMANDS.contains(&cmd.name.as_str()) {
131                anyhow::bail!(
132                    "plugin '{}' cannot register command '{}': reserved by built-in",
133                    plugin.name(),
134                    cmd.name
135                );
136            }
137            if let Some(&existing_idx) = self.command_map.get(&cmd.name) {
138                anyhow::bail!(
139                    "plugin '{}' cannot register command '{}': already registered by '{}'",
140                    plugin.name(),
141                    cmd.name,
142                    self.plugins[existing_idx].name()
143                );
144            }
145            self.command_map.insert(cmd.name.clone(), idx);
146        }
147        self.plugins.push(plugin);
148        Ok(())
149    }
150
151    /// Look up which plugin handles a command name.
152    pub fn resolve(&self, command: &str) -> Option<&dyn Plugin> {
153        self.command_map
154            .get(command)
155            .map(|&idx| self.plugins[idx].as_ref())
156    }
157
158    /// All registered commands across all plugins.
159    pub fn all_commands(&self) -> Vec<CommandInfo> {
160        self.plugins.iter().flat_map(|p| p.commands()).collect()
161    }
162
163    /// Notify all registered plugins that a user has joined the room.
164    ///
165    /// Calls [`Plugin::on_user_join`] on every plugin in registration order.
166    pub fn notify_join(&self, user: &str) {
167        for plugin in &self.plugins {
168            plugin.on_user_join(user);
169        }
170    }
171
172    /// Notify all registered plugins that a user has left the room.
173    ///
174    /// Calls [`Plugin::on_user_leave`] on every plugin in registration order.
175    pub fn notify_leave(&self, user: &str) {
176        for plugin in &self.plugins {
177            plugin.on_user_leave(user);
178        }
179    }
180
181    /// Notify all registered plugins that a message was broadcast.
182    ///
183    /// Calls [`Plugin::on_message`] on every plugin in registration order.
184    pub fn notify_message(&self, msg: &room_protocol::Message) {
185        for plugin in &self.plugins {
186            plugin.on_message(msg);
187        }
188    }
189
190    /// Completions for a specific command at a given argument position,
191    /// derived from the parameter schema.
192    ///
193    /// Returns `Choice` values for `ParamType::Choice` parameters, or an
194    /// empty vec for freeform/username/number parameters.
195    pub fn completions_for(&self, command: &str, arg_pos: usize) -> Vec<String> {
196        self.all_commands()
197            .iter()
198            .find(|c| c.name == command)
199            .and_then(|c| c.params.get(arg_pos))
200            .map(|p| match &p.param_type {
201                ParamType::Choice(values) => values.clone(),
202                _ => vec![],
203            })
204            .unwrap_or_default()
205    }
206}
207
208impl Default for PluginRegistry {
209    fn default() -> Self {
210        Self::new()
211    }
212}
213
214/// Returns `true` if `running >= required` using semver major.minor.patch
215/// comparison. Malformed versions are treated as `(0, 0, 0)`.
216fn semver_satisfies(running: &str, required: &str) -> bool {
217    let parse = |s: &str| -> (u64, u64, u64) {
218        let mut parts = s.split('.');
219        let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
220        let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
221        let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
222        (major, minor, patch)
223    };
224    parse(running) >= parse(required)
225}
226
227// ── Tests ───────────────────────────────────────────────────────────────────
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    struct DummyPlugin {
234        name: &'static str,
235        cmd: &'static str,
236    }
237
238    impl Plugin for DummyPlugin {
239        fn name(&self) -> &str {
240            self.name
241        }
242
243        fn commands(&self) -> Vec<CommandInfo> {
244            vec![CommandInfo {
245                name: self.cmd.to_owned(),
246                description: "dummy".to_owned(),
247                usage: format!("/{}", self.cmd),
248                params: vec![],
249                subcommands: vec![],
250            }]
251        }
252
253        fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
254            Box::pin(async { Ok(PluginResult::Reply("dummy".to_owned(), None)) })
255        }
256    }
257
258    #[test]
259    fn registry_register_and_resolve() {
260        let mut reg = PluginRegistry::new();
261        reg.register(Box::new(DummyPlugin {
262            name: "test",
263            cmd: "foo",
264        }))
265        .unwrap();
266        assert!(reg.resolve("foo").is_some());
267        assert!(reg.resolve("bar").is_none());
268    }
269
270    #[test]
271    fn registry_rejects_reserved_command() {
272        let mut reg = PluginRegistry::new();
273        let result = reg.register(Box::new(DummyPlugin {
274            name: "bad",
275            cmd: "kick",
276        }));
277        assert!(result.is_err());
278        let err = result.unwrap_err().to_string();
279        assert!(err.contains("reserved by built-in"));
280    }
281
282    #[test]
283    fn registry_rejects_duplicate_command() {
284        let mut reg = PluginRegistry::new();
285        reg.register(Box::new(DummyPlugin {
286            name: "first",
287            cmd: "foo",
288        }))
289        .unwrap();
290        let result = reg.register(Box::new(DummyPlugin {
291            name: "second",
292            cmd: "foo",
293        }));
294        assert!(result.is_err());
295        let err = result.unwrap_err().to_string();
296        assert!(err.contains("already registered by 'first'"));
297    }
298
299    #[test]
300    fn registry_all_commands_lists_everything() {
301        let mut reg = PluginRegistry::new();
302        reg.register(Box::new(DummyPlugin {
303            name: "a",
304            cmd: "alpha",
305        }))
306        .unwrap();
307        reg.register(Box::new(DummyPlugin {
308            name: "b",
309            cmd: "beta",
310        }))
311        .unwrap();
312        let cmds = reg.all_commands();
313        let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
314        assert!(names.contains(&"alpha"));
315        assert!(names.contains(&"beta"));
316        assert_eq!(names.len(), 2);
317    }
318
319    #[test]
320    fn registry_completions_for_returns_choice_values() {
321        let mut reg = PluginRegistry::new();
322        reg.register(Box::new({
323            struct CompPlugin;
324            impl Plugin for CompPlugin {
325                fn name(&self) -> &str {
326                    "comp"
327                }
328                fn commands(&self) -> Vec<CommandInfo> {
329                    vec![CommandInfo {
330                        name: "test".to_owned(),
331                        description: "test".to_owned(),
332                        usage: "/test".to_owned(),
333                        params: vec![ParamSchema {
334                            name: "count".to_owned(),
335                            param_type: ParamType::Choice(vec!["10".to_owned(), "20".to_owned()]),
336                            required: false,
337                            description: "Number of items".to_owned(),
338                        }],
339                        subcommands: vec![],
340                    }]
341                }
342                fn handle(
343                    &self,
344                    _ctx: CommandContext,
345                ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
346                    Box::pin(async { Ok(PluginResult::Handled) })
347                }
348            }
349            CompPlugin
350        }))
351        .unwrap();
352        let completions = reg.completions_for("test", 0);
353        assert_eq!(completions, vec!["10", "20"]);
354        assert!(reg.completions_for("test", 1).is_empty());
355        assert!(reg.completions_for("nonexistent", 0).is_empty());
356    }
357
358    #[test]
359    fn registry_completions_for_non_choice_returns_empty() {
360        let mut reg = PluginRegistry::new();
361        reg.register(Box::new({
362            struct TextPlugin;
363            impl Plugin for TextPlugin {
364                fn name(&self) -> &str {
365                    "text"
366                }
367                fn commands(&self) -> Vec<CommandInfo> {
368                    vec![CommandInfo {
369                        name: "echo".to_owned(),
370                        description: "echo".to_owned(),
371                        usage: "/echo".to_owned(),
372                        params: vec![ParamSchema {
373                            name: "msg".to_owned(),
374                            param_type: ParamType::Text,
375                            required: true,
376                            description: "Message".to_owned(),
377                        }],
378                        subcommands: vec![],
379                    }]
380                }
381                fn handle(
382                    &self,
383                    _ctx: CommandContext,
384                ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
385                    Box::pin(async { Ok(PluginResult::Handled) })
386                }
387            }
388            TextPlugin
389        }))
390        .unwrap();
391        // Text params produce no completions
392        assert!(reg.completions_for("echo", 0).is_empty());
393    }
394
395    #[test]
396    fn registry_rejects_all_reserved_commands() {
397        for &reserved in RESERVED_COMMANDS {
398            let mut reg = PluginRegistry::new();
399            let result = reg.register(Box::new(DummyPlugin {
400                name: "bad",
401                cmd: reserved,
402            }));
403            assert!(
404                result.is_err(),
405                "should reject reserved command '{reserved}'"
406            );
407        }
408    }
409
410    // Schema tests (builtin_command_infos, all_known_commands) live in schema.rs.
411    // HistoryReader tests live in bridge.rs alongside the implementation.
412
413    // ── Plugin trait default methods ──────────────────────────────────────
414
415    /// A plugin that only provides a name and handle — no commands override,
416    /// no lifecycle hooks override. Demonstrates the defaults compile and work.
417    struct MinimalPlugin;
418
419    impl Plugin for MinimalPlugin {
420        fn name(&self) -> &str {
421            "minimal"
422        }
423
424        fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
425            Box::pin(async { Ok(PluginResult::Handled) })
426        }
427        // commands(), on_user_join(), on_user_leave() all use defaults
428    }
429
430    #[test]
431    fn default_commands_returns_empty_vec() {
432        assert!(MinimalPlugin.commands().is_empty());
433    }
434
435    #[test]
436    fn default_lifecycle_hooks_are_noop() {
437        // These should not panic or do anything observable
438        MinimalPlugin.on_user_join("alice");
439        MinimalPlugin.on_user_leave("alice");
440    }
441
442    #[test]
443    fn registry_notify_join_calls_all_plugins() {
444        use std::sync::{Arc, Mutex};
445
446        struct TrackingPlugin {
447            joined: Arc<Mutex<Vec<String>>>,
448            left: Arc<Mutex<Vec<String>>>,
449        }
450
451        impl Plugin for TrackingPlugin {
452            fn name(&self) -> &str {
453                "tracking"
454            }
455
456            fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
457                Box::pin(async { Ok(PluginResult::Handled) })
458            }
459
460            fn on_user_join(&self, user: &str) {
461                self.joined.lock().unwrap().push(user.to_owned());
462            }
463
464            fn on_user_leave(&self, user: &str) {
465                self.left.lock().unwrap().push(user.to_owned());
466            }
467        }
468
469        let joined = Arc::new(Mutex::new(Vec::<String>::new()));
470        let left = Arc::new(Mutex::new(Vec::<String>::new()));
471        let mut reg = PluginRegistry::new();
472        reg.register(Box::new(TrackingPlugin {
473            joined: joined.clone(),
474            left: left.clone(),
475        }))
476        .unwrap();
477
478        reg.notify_join("alice");
479        reg.notify_join("bob");
480        reg.notify_leave("alice");
481
482        assert_eq!(*joined.lock().unwrap(), vec!["alice", "bob"]);
483        assert_eq!(*left.lock().unwrap(), vec!["alice"]);
484    }
485
486    #[test]
487    fn registry_notify_join_empty_registry_is_noop() {
488        let reg = PluginRegistry::new();
489        // Should not panic with zero plugins
490        reg.notify_join("alice");
491        reg.notify_leave("alice");
492    }
493
494    #[test]
495    fn registry_notify_message_calls_all_plugins() {
496        use std::sync::{Arc, Mutex};
497
498        struct MessageTracker {
499            messages: Arc<Mutex<Vec<String>>>,
500        }
501
502        impl Plugin for MessageTracker {
503            fn name(&self) -> &str {
504                "msg-tracker"
505            }
506
507            fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
508                Box::pin(async { Ok(PluginResult::Handled) })
509            }
510
511            fn on_message(&self, msg: &room_protocol::Message) {
512                self.messages.lock().unwrap().push(msg.user().to_owned());
513            }
514        }
515
516        let messages = Arc::new(Mutex::new(Vec::<String>::new()));
517        let mut reg = PluginRegistry::new();
518        reg.register(Box::new(MessageTracker {
519            messages: messages.clone(),
520        }))
521        .unwrap();
522
523        let msg = room_protocol::make_message("room", "alice", "hello");
524        reg.notify_message(&msg);
525        reg.notify_message(&room_protocol::make_message("room", "bob", "hi"));
526
527        let recorded = messages.lock().unwrap();
528        assert_eq!(*recorded, vec!["alice", "bob"]);
529    }
530
531    #[test]
532    fn registry_notify_message_empty_registry_is_noop() {
533        let reg = PluginRegistry::new();
534        let msg = room_protocol::make_message("room", "alice", "hello");
535        // Should not panic with zero plugins
536        reg.notify_message(&msg);
537    }
538
539    #[test]
540    fn minimal_plugin_can_be_registered_without_commands() {
541        let mut reg = PluginRegistry::new();
542        // MinimalPlugin has no commands, so registration must succeed
543        // (the only validation in register() is command name conflicts)
544        reg.register(Box::new(MinimalPlugin)).unwrap();
545        // It won't show up in resolve() since it has no commands
546        assert_eq!(reg.all_commands().len(), 0);
547    }
548
549    // ── Edge-case tests (#577) ───────────────────────────────────────────
550
551    #[test]
552    fn failed_register_does_not_pollute_registry() {
553        let mut reg = PluginRegistry::new();
554        reg.register(Box::new(DummyPlugin {
555            name: "good",
556            cmd: "foo",
557        }))
558        .unwrap();
559
560        // Attempt to register a plugin with a reserved command name — must fail.
561        let result = reg.register(Box::new(DummyPlugin {
562            name: "bad",
563            cmd: "kick",
564        }));
565        assert!(result.is_err());
566
567        // Original registration must be intact.
568        assert!(
569            reg.resolve("foo").is_some(),
570            "pre-existing command must still resolve"
571        );
572        assert_eq!(reg.all_commands().len(), 1, "command count must not change");
573        // The failed plugin must not appear in any form.
574        assert!(
575            reg.resolve("kick").is_none(),
576            "failed command must not be resolvable"
577        );
578    }
579
580    #[test]
581    fn all_builtin_schemas_have_valid_fields() {
582        let cmds = super::schema::builtin_command_infos();
583        assert!(!cmds.is_empty(), "builtins must not be empty");
584        for cmd in &cmds {
585            assert!(!cmd.name.is_empty(), "name must not be empty");
586            assert!(
587                !cmd.description.is_empty(),
588                "description must not be empty for /{}",
589                cmd.name
590            );
591            assert!(
592                !cmd.usage.is_empty(),
593                "usage must not be empty for /{}",
594                cmd.name
595            );
596            for param in &cmd.params {
597                assert!(
598                    !param.name.is_empty(),
599                    "param name must not be empty in /{}",
600                    cmd.name
601                );
602                assert!(
603                    !param.description.is_empty(),
604                    "param description must not be empty in /{} param '{}'",
605                    cmd.name,
606                    param.name
607                );
608            }
609        }
610    }
611
612    #[test]
613    fn duplicate_plugin_names_with_different_commands_succeed() {
614        let mut reg = PluginRegistry::new();
615        reg.register(Box::new(DummyPlugin {
616            name: "same-name",
617            cmd: "alpha",
618        }))
619        .unwrap();
620        // Same plugin name, different command — only command uniqueness is enforced.
621        reg.register(Box::new(DummyPlugin {
622            name: "same-name",
623            cmd: "beta",
624        }))
625        .unwrap();
626        assert!(reg.resolve("alpha").is_some());
627        assert!(reg.resolve("beta").is_some());
628        assert_eq!(reg.all_commands().len(), 2);
629    }
630
631    #[test]
632    fn completions_for_number_param_returns_empty() {
633        let mut reg = PluginRegistry::new();
634        reg.register(Box::new({
635            struct NumPlugin;
636            impl Plugin for NumPlugin {
637                fn name(&self) -> &str {
638                    "num"
639                }
640                fn commands(&self) -> Vec<CommandInfo> {
641                    vec![CommandInfo {
642                        name: "repeat".to_owned(),
643                        description: "repeat".to_owned(),
644                        usage: "/repeat".to_owned(),
645                        params: vec![ParamSchema {
646                            name: "count".to_owned(),
647                            param_type: ParamType::Number {
648                                min: Some(1),
649                                max: Some(100),
650                            },
651                            required: true,
652                            description: "Number of repetitions".to_owned(),
653                        }],
654                        subcommands: vec![],
655                    }]
656                }
657                fn handle(
658                    &self,
659                    _ctx: CommandContext,
660                ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
661                    Box::pin(async { Ok(PluginResult::Handled) })
662                }
663            }
664            NumPlugin
665        }))
666        .unwrap();
667        // Number params must not produce completions — only Choice does.
668        assert!(reg.completions_for("repeat", 0).is_empty());
669    }
670
671    // ── semver_satisfies tests ──────────────────────────────────────────
672
673    #[test]
674    fn semver_satisfies_equal_versions() {
675        assert!(super::semver_satisfies("3.1.0", "3.1.0"));
676    }
677
678    #[test]
679    fn semver_satisfies_running_newer_major() {
680        assert!(super::semver_satisfies("4.0.0", "3.1.0"));
681    }
682
683    #[test]
684    fn semver_satisfies_running_newer_minor() {
685        assert!(super::semver_satisfies("3.2.0", "3.1.0"));
686    }
687
688    #[test]
689    fn semver_satisfies_running_newer_patch() {
690        assert!(super::semver_satisfies("3.1.1", "3.1.0"));
691    }
692
693    #[test]
694    fn semver_satisfies_running_older_fails() {
695        assert!(!super::semver_satisfies("3.0.9", "3.1.0"));
696    }
697
698    #[test]
699    fn semver_satisfies_running_older_major_fails() {
700        assert!(!super::semver_satisfies("2.9.9", "3.0.0"));
701    }
702
703    #[test]
704    fn semver_satisfies_zero_required_always_passes() {
705        assert!(super::semver_satisfies("0.0.1", "0.0.0"));
706        assert!(super::semver_satisfies("3.1.0", "0.0.0"));
707    }
708
709    #[test]
710    fn semver_satisfies_malformed_treated_as_zero() {
711        assert!(super::semver_satisfies("garbage", "0.0.0"));
712        assert!(super::semver_satisfies("3.1.0", "garbage"));
713        assert!(super::semver_satisfies("garbage", "garbage"));
714    }
715
716    // ── Version compatibility in register() ─────────────────────────────
717
718    /// A plugin that reports a future api_version the broker does not support.
719    struct FutureApiPlugin;
720
721    impl Plugin for FutureApiPlugin {
722        fn name(&self) -> &str {
723            "future-api"
724        }
725
726        fn api_version(&self) -> u32 {
727            PLUGIN_API_VERSION + 1
728        }
729
730        fn commands(&self) -> Vec<CommandInfo> {
731            vec![CommandInfo {
732                name: "future".to_owned(),
733                description: "from the future".to_owned(),
734                usage: "/future".to_owned(),
735                params: vec![],
736                subcommands: vec![],
737            }]
738        }
739
740        fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
741            Box::pin(async { Ok(PluginResult::Handled) })
742        }
743    }
744
745    #[test]
746    fn register_rejects_future_api_version() {
747        let mut reg = PluginRegistry::new();
748        let result = reg.register(Box::new(FutureApiPlugin));
749        assert!(result.is_err());
750        let err = result.unwrap_err().to_string();
751        assert!(
752            err.contains("api_version"),
753            "error should mention api_version: {err}"
754        );
755        assert!(
756            err.contains("future-api"),
757            "error should mention plugin name: {err}"
758        );
759    }
760
761    /// A plugin that requires a protocol version newer than what we have.
762    struct FutureProtocolPlugin;
763
764    impl Plugin for FutureProtocolPlugin {
765        fn name(&self) -> &str {
766            "future-proto"
767        }
768
769        fn min_protocol(&self) -> &str {
770            "99.0.0"
771        }
772
773        fn commands(&self) -> Vec<CommandInfo> {
774            vec![CommandInfo {
775                name: "proto".to_owned(),
776                description: "needs future protocol".to_owned(),
777                usage: "/proto".to_owned(),
778                params: vec![],
779                subcommands: vec![],
780            }]
781        }
782
783        fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
784            Box::pin(async { Ok(PluginResult::Handled) })
785        }
786    }
787
788    #[test]
789    fn register_rejects_incompatible_min_protocol() {
790        let mut reg = PluginRegistry::new();
791        let result = reg.register(Box::new(FutureProtocolPlugin));
792        assert!(result.is_err());
793        let err = result.unwrap_err().to_string();
794        assert!(
795            err.contains("room-protocol"),
796            "error should mention room-protocol: {err}"
797        );
798        assert!(
799            err.contains("99.0.0"),
800            "error should mention required version: {err}"
801        );
802    }
803
804    #[test]
805    fn register_accepts_compatible_versioned_plugin() {
806        let mut reg = PluginRegistry::new();
807        // DummyPlugin uses defaults: api_version=1, min_protocol="0.0.0"
808        let result = reg.register(Box::new(DummyPlugin {
809            name: "compat",
810            cmd: "compat_cmd",
811        }));
812        assert!(result.is_ok());
813        assert!(reg.resolve("compat_cmd").is_some());
814    }
815
816    #[test]
817    fn register_version_check_runs_before_command_check() {
818        // A plugin with a future api_version AND a reserved command name.
819        // The api_version check should fire first.
820        struct DoubleBadPlugin;
821
822        impl Plugin for DoubleBadPlugin {
823            fn name(&self) -> &str {
824                "double-bad"
825            }
826
827            fn api_version(&self) -> u32 {
828                PLUGIN_API_VERSION + 1
829            }
830
831            fn commands(&self) -> Vec<CommandInfo> {
832                vec![CommandInfo {
833                    name: "kick".to_owned(),
834                    description: "bad".to_owned(),
835                    usage: "/kick".to_owned(),
836                    params: vec![],
837                    subcommands: vec![],
838                }]
839            }
840
841            fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
842                Box::pin(async { Ok(PluginResult::Handled) })
843            }
844        }
845
846        let mut reg = PluginRegistry::new();
847        let result = reg.register(Box::new(DoubleBadPlugin));
848        assert!(result.is_err());
849        let err = result.unwrap_err().to_string();
850        // Should fail on api_version, not on the reserved command
851        assert!(
852            err.contains("api_version"),
853            "should reject on api_version first: {err}"
854        );
855    }
856
857    #[test]
858    fn failed_version_check_does_not_pollute_registry() {
859        let mut reg = PluginRegistry::new();
860        reg.register(Box::new(DummyPlugin {
861            name: "good",
862            cmd: "foo",
863        }))
864        .unwrap();
865
866        // Attempt to register a plugin with incompatible protocol
867        let result = reg.register(Box::new(FutureProtocolPlugin));
868        assert!(result.is_err());
869
870        // Original registration must be intact
871        assert!(reg.resolve("foo").is_some());
872        assert_eq!(reg.all_commands().len(), 1);
873        // Failed plugin's command must not appear
874        assert!(reg.resolve("proto").is_none());
875    }
876}