Skip to main content

room_daemon/plugin/
mod.rs

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