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