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