1pub mod bridge;
2pub mod loader;
3pub mod queue;
4pub mod schema;
5pub mod stats;
6
7pub use room_plugin_taskboard as taskboard;
9
10use std::{collections::HashMap, path::Path};
11
12pub 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
19pub(crate) use bridge::snapshot_metadata;
22pub use bridge::{ChatWriter, HistoryReader, TeamChecker};
23pub use schema::{all_known_commands, builtin_command_infos};
24
25const 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
50pub struct PluginRegistry {
52 plugins: Vec<Box<dyn Plugin>>,
53 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 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 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 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 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 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 pub fn register(&mut self, plugin: Box<dyn Plugin>) -> anyhow::Result<()> {
118 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 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 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 pub fn all_commands(&self) -> Vec<CommandInfo> {
168 self.plugins.iter().flat_map(|p| p.commands()).collect()
169 }
170
171 pub fn notify_join(&self, user: &str) {
175 for plugin in &self.plugins {
176 plugin.on_user_join(user);
177 }
178 }
179
180 pub fn notify_leave(&self, user: &str) {
184 for plugin in &self.plugins {
185 plugin.on_user_leave(user);
186 }
187 }
188
189 pub fn notify_message(&self, msg: &room_protocol::Message) {
193 for plugin in &self.plugins {
194 plugin.on_message(msg);
195 }
196 }
197
198 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
222fn 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#[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 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 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 }
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 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 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 reg.notify_message(&msg);
542 }
543
544 #[test]
545 fn minimal_plugin_can_be_registered_without_commands() {
546 let mut reg = PluginRegistry::new();
547 reg.register(Box::new(MinimalPlugin)).unwrap();
550 assert_eq!(reg.all_commands().len(), 0);
552 }
553
554 #[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 let result = reg.register(Box::new(DummyPlugin {
567 name: "bad",
568 cmd: "kick",
569 }));
570 assert!(result.is_err());
571
572 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 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 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 assert!(reg.completions_for("repeat", 0).is_empty());
673 }
674
675 #[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 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 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 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 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 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 let result = reg.register(Box::new(FutureProtocolPlugin));
869 assert!(result.is_err());
870
871 assert!(reg.resolve("foo").is_some());
873 assert_eq!(reg.all_commands().len(), 1);
874 assert!(reg.resolve("proto").is_none());
876 }
877}