1pub mod queue;
2pub mod stats;
3pub mod taskboard;
4
5use std::{
6 collections::HashMap,
7 future::Future,
8 path::{Path, PathBuf},
9 pin::Pin,
10 sync::{
11 atomic::{AtomicU64, Ordering},
12 Arc,
13 },
14};
15
16use chrono::{DateTime, Utc};
17
18use crate::{
19 broker::{
20 fanout::broadcast_and_persist,
21 state::{ClientMap, StatusMap},
22 },
23 history,
24 message::{make_event, make_system, EventType, Message},
25};
26
27pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
29
30pub trait Plugin: Send + Sync {
45 fn name(&self) -> &str;
47
48 fn commands(&self) -> Vec<CommandInfo> {
54 vec![]
55 }
56
57 fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>>;
62
63 fn on_user_join(&self, _user: &str) {}
68
69 fn on_user_leave(&self, _user: &str) {}
74}
75
76#[derive(Debug, Clone)]
80pub struct CommandInfo {
81 pub name: String,
83 pub description: String,
85 pub usage: String,
87 pub params: Vec<ParamSchema>,
89}
90
91#[derive(Debug, Clone)]
96pub struct ParamSchema {
97 pub name: String,
99 pub param_type: ParamType,
101 pub required: bool,
103 pub description: String,
105}
106
107#[derive(Debug, Clone, PartialEq)]
109pub enum ParamType {
110 Text,
112 Choice(Vec<String>),
114 Username,
116 Number { min: Option<i64>, max: Option<i64> },
118}
119
120pub struct CommandContext {
124 pub command: String,
126 pub params: Vec<String>,
128 pub sender: String,
130 pub room_id: String,
132 pub message_id: String,
134 pub timestamp: DateTime<Utc>,
136 pub history: HistoryReader,
138 pub writer: ChatWriter,
140 pub metadata: RoomMetadata,
142 pub available_commands: Vec<CommandInfo>,
145}
146
147pub enum PluginResult {
151 Reply(String),
153 Broadcast(String),
155 Handled,
157}
158
159pub struct HistoryReader {
166 chat_path: PathBuf,
167 viewer: String,
168}
169
170impl HistoryReader {
171 pub(crate) fn new(chat_path: &Path, viewer: &str) -> Self {
172 Self {
173 chat_path: chat_path.to_owned(),
174 viewer: viewer.to_owned(),
175 }
176 }
177
178 pub async fn all(&self) -> anyhow::Result<Vec<Message>> {
180 let all = history::load(&self.chat_path).await?;
181 Ok(self.filter_dms(all))
182 }
183
184 pub async fn tail(&self, n: usize) -> anyhow::Result<Vec<Message>> {
186 let all = history::tail(&self.chat_path, n).await?;
187 Ok(self.filter_dms(all))
188 }
189
190 pub async fn since(&self, message_id: &str) -> anyhow::Result<Vec<Message>> {
192 let all = history::load(&self.chat_path).await?;
193 let start = all
194 .iter()
195 .position(|m| m.id() == message_id)
196 .map(|i| i + 1)
197 .unwrap_or(0);
198 Ok(self.filter_dms(all[start..].to_vec()))
199 }
200
201 pub async fn count(&self) -> anyhow::Result<usize> {
203 let all = history::load(&self.chat_path).await?;
204 Ok(all.len())
205 }
206
207 fn filter_dms(&self, messages: Vec<Message>) -> Vec<Message> {
208 messages
209 .into_iter()
210 .filter(|m| match m {
211 Message::DirectMessage { user, to, .. } => {
212 user == &self.viewer || to == &self.viewer
213 }
214 _ => true,
215 })
216 .collect()
217 }
218}
219
220pub struct ChatWriter {
227 clients: ClientMap,
228 chat_path: Arc<PathBuf>,
229 room_id: Arc<String>,
230 seq_counter: Arc<AtomicU64>,
231 identity: String,
233}
234
235impl ChatWriter {
236 pub(crate) fn new(
237 clients: &ClientMap,
238 chat_path: &Arc<PathBuf>,
239 room_id: &Arc<String>,
240 seq_counter: &Arc<AtomicU64>,
241 plugin_name: &str,
242 ) -> Self {
243 Self {
244 clients: clients.clone(),
245 chat_path: chat_path.clone(),
246 room_id: room_id.clone(),
247 seq_counter: seq_counter.clone(),
248 identity: format!("plugin:{plugin_name}"),
249 }
250 }
251
252 pub async fn broadcast(&self, content: &str) -> anyhow::Result<()> {
254 let msg = make_system(&self.room_id, &self.identity, content);
255 broadcast_and_persist(&msg, &self.clients, &self.chat_path, &self.seq_counter).await?;
256 Ok(())
257 }
258
259 pub async fn reply_to(&self, username: &str, content: &str) -> anyhow::Result<()> {
261 let msg = make_system(&self.room_id, &self.identity, content);
262 let seq = self.seq_counter.fetch_add(1, Ordering::SeqCst) + 1;
263 let mut msg = msg;
264 msg.set_seq(seq);
265 history::append(&self.chat_path, &msg).await?;
266
267 let line = format!("{}\n", serde_json::to_string(&msg)?);
268 let map = self.clients.lock().await;
269 for (uname, tx) in map.values() {
270 if uname == username {
271 let _ = tx.send(line.clone());
272 }
273 }
274 Ok(())
275 }
276
277 pub async fn emit_event(
279 &self,
280 event_type: EventType,
281 content: &str,
282 params: Option<serde_json::Value>,
283 ) -> anyhow::Result<()> {
284 let msg = make_event(&self.room_id, &self.identity, event_type, content, params);
285 broadcast_and_persist(&msg, &self.clients, &self.chat_path, &self.seq_counter).await?;
286 Ok(())
287 }
288}
289
290pub struct RoomMetadata {
294 pub online_users: Vec<UserInfo>,
296 pub host: Option<String>,
298 pub message_count: usize,
300}
301
302pub struct UserInfo {
304 pub username: String,
305 pub status: String,
306}
307
308impl RoomMetadata {
309 pub(crate) async fn snapshot(
310 status_map: &StatusMap,
311 host_user: &Arc<tokio::sync::Mutex<Option<String>>>,
312 chat_path: &Path,
313 ) -> Self {
314 let map = status_map.lock().await;
315 let online_users: Vec<UserInfo> = map
316 .iter()
317 .map(|(u, s)| UserInfo {
318 username: u.clone(),
319 status: s.clone(),
320 })
321 .collect();
322 drop(map);
323
324 let host = host_user.lock().await.clone();
325
326 let message_count = history::load(chat_path)
327 .await
328 .map(|msgs| msgs.len())
329 .unwrap_or(0);
330
331 Self {
332 online_users,
333 host,
334 message_count,
335 }
336 }
337}
338
339const RESERVED_COMMANDS: &[&str] = &[
343 "who",
344 "help",
345 "info",
346 "kick",
347 "reauth",
348 "clear-tokens",
349 "dm",
350 "reply",
351 "room-info",
352 "exit",
353 "clear",
354 "subscribe",
355 "set_subscription",
356 "unsubscribe",
357 "subscribe_events",
358 "set_event_filter",
359 "set_status",
360 "subscriptions",
361];
362
363pub struct PluginRegistry {
365 plugins: Vec<Box<dyn Plugin>>,
366 command_map: HashMap<String, usize>,
368}
369
370impl PluginRegistry {
371 pub fn new() -> Self {
372 Self {
373 plugins: Vec::new(),
374 command_map: HashMap::new(),
375 }
376 }
377
378 pub(crate) fn with_all_plugins(chat_path: &Path) -> anyhow::Result<Self> {
383 let mut registry = Self::new();
384
385 let queue_path = queue::QueuePlugin::queue_path_from_chat(chat_path);
386 registry.register(Box::new(queue::QueuePlugin::new(queue_path)?))?;
387
388 registry.register(Box::new(stats::StatsPlugin))?;
389
390 let taskboard_path = taskboard::TaskboardPlugin::taskboard_path_from_chat(chat_path);
391 registry.register(Box::new(taskboard::TaskboardPlugin::new(
392 taskboard_path,
393 None,
394 )))?;
395
396 Ok(registry)
397 }
398
399 pub fn register(&mut self, plugin: Box<dyn Plugin>) -> anyhow::Result<()> {
402 let idx = self.plugins.len();
403 for cmd in plugin.commands() {
404 if RESERVED_COMMANDS.contains(&cmd.name.as_str()) {
405 anyhow::bail!(
406 "plugin '{}' cannot register command '{}': reserved by built-in",
407 plugin.name(),
408 cmd.name
409 );
410 }
411 if let Some(&existing_idx) = self.command_map.get(&cmd.name) {
412 anyhow::bail!(
413 "plugin '{}' cannot register command '{}': already registered by '{}'",
414 plugin.name(),
415 cmd.name,
416 self.plugins[existing_idx].name()
417 );
418 }
419 self.command_map.insert(cmd.name.clone(), idx);
420 }
421 self.plugins.push(plugin);
422 Ok(())
423 }
424
425 pub fn resolve(&self, command: &str) -> Option<&dyn Plugin> {
427 self.command_map
428 .get(command)
429 .map(|&idx| self.plugins[idx].as_ref())
430 }
431
432 pub fn all_commands(&self) -> Vec<CommandInfo> {
434 self.plugins.iter().flat_map(|p| p.commands()).collect()
435 }
436
437 pub fn notify_join(&self, user: &str) {
441 for plugin in &self.plugins {
442 plugin.on_user_join(user);
443 }
444 }
445
446 pub fn notify_leave(&self, user: &str) {
450 for plugin in &self.plugins {
451 plugin.on_user_leave(user);
452 }
453 }
454
455 pub fn completions_for(&self, command: &str, arg_pos: usize) -> Vec<String> {
461 self.all_commands()
462 .iter()
463 .find(|c| c.name == command)
464 .and_then(|c| c.params.get(arg_pos))
465 .map(|p| match &p.param_type {
466 ParamType::Choice(values) => values.clone(),
467 _ => vec![],
468 })
469 .unwrap_or_default()
470 }
471}
472
473impl Default for PluginRegistry {
474 fn default() -> Self {
475 Self::new()
476 }
477}
478
479pub fn builtin_command_infos() -> Vec<CommandInfo> {
485 vec![
486 CommandInfo {
487 name: "dm".to_owned(),
488 description: "Send a private message".to_owned(),
489 usage: "/dm <user> <message>".to_owned(),
490 params: vec![
491 ParamSchema {
492 name: "user".to_owned(),
493 param_type: ParamType::Username,
494 required: true,
495 description: "Recipient username".to_owned(),
496 },
497 ParamSchema {
498 name: "message".to_owned(),
499 param_type: ParamType::Text,
500 required: true,
501 description: "Message content".to_owned(),
502 },
503 ],
504 },
505 CommandInfo {
506 name: "reply".to_owned(),
507 description: "Reply to a message".to_owned(),
508 usage: "/reply <id> <message>".to_owned(),
509 params: vec![
510 ParamSchema {
511 name: "id".to_owned(),
512 param_type: ParamType::Text,
513 required: true,
514 description: "Message ID to reply to".to_owned(),
515 },
516 ParamSchema {
517 name: "message".to_owned(),
518 param_type: ParamType::Text,
519 required: true,
520 description: "Reply content".to_owned(),
521 },
522 ],
523 },
524 CommandInfo {
525 name: "who".to_owned(),
526 description: "List users in the room".to_owned(),
527 usage: "/who".to_owned(),
528 params: vec![],
529 },
530 CommandInfo {
531 name: "kick".to_owned(),
532 description: "Kick a user from the room".to_owned(),
533 usage: "/kick <user>".to_owned(),
534 params: vec![ParamSchema {
535 name: "user".to_owned(),
536 param_type: ParamType::Username,
537 required: true,
538 description: "User to kick (host only)".to_owned(),
539 }],
540 },
541 CommandInfo {
542 name: "reauth".to_owned(),
543 description: "Invalidate a user's token".to_owned(),
544 usage: "/reauth <user>".to_owned(),
545 params: vec![ParamSchema {
546 name: "user".to_owned(),
547 param_type: ParamType::Username,
548 required: true,
549 description: "User to reauth (host only)".to_owned(),
550 }],
551 },
552 CommandInfo {
553 name: "clear-tokens".to_owned(),
554 description: "Revoke all session tokens".to_owned(),
555 usage: "/clear-tokens".to_owned(),
556 params: vec![],
557 },
558 CommandInfo {
559 name: "exit".to_owned(),
560 description: "Shut down the broker".to_owned(),
561 usage: "/exit".to_owned(),
562 params: vec![],
563 },
564 CommandInfo {
565 name: "clear".to_owned(),
566 description: "Clear the room history".to_owned(),
567 usage: "/clear".to_owned(),
568 params: vec![],
569 },
570 CommandInfo {
571 name: "info".to_owned(),
572 description: "Show room metadata or user info".to_owned(),
573 usage: "/info [username]".to_owned(),
574 params: vec![ParamSchema {
575 name: "username".to_owned(),
576 param_type: ParamType::Username,
577 required: false,
578 description: "User to inspect (omit for room info)".to_owned(),
579 }],
580 },
581 CommandInfo {
582 name: "room-info".to_owned(),
583 description: "Alias for /info — show room visibility, config, and member count"
584 .to_owned(),
585 usage: "/room-info".to_owned(),
586 params: vec![],
587 },
588 CommandInfo {
589 name: "subscribe".to_owned(),
590 description: "Subscribe to this room".to_owned(),
591 usage: "/subscribe [tier]".to_owned(),
592 params: vec![ParamSchema {
593 name: "tier".to_owned(),
594 param_type: ParamType::Choice(vec!["full".to_owned(), "mentions_only".to_owned()]),
595 required: false,
596 description: "Subscription tier (default: full)".to_owned(),
597 }],
598 },
599 CommandInfo {
600 name: "set_subscription".to_owned(),
601 description: "Alias for /subscribe — set subscription tier for this room".to_owned(),
602 usage: "/set_subscription [tier]".to_owned(),
603 params: vec![ParamSchema {
604 name: "tier".to_owned(),
605 param_type: ParamType::Choice(vec!["full".to_owned(), "mentions_only".to_owned()]),
606 required: false,
607 description: "Subscription tier (default: full)".to_owned(),
608 }],
609 },
610 CommandInfo {
611 name: "unsubscribe".to_owned(),
612 description: "Unsubscribe from this room".to_owned(),
613 usage: "/unsubscribe".to_owned(),
614 params: vec![],
615 },
616 CommandInfo {
617 name: "subscribe_events".to_owned(),
618 description: "Set event type filter for this room".to_owned(),
619 usage: "/subscribe_events [filter]".to_owned(),
620 params: vec![ParamSchema {
621 name: "filter".to_owned(),
622 param_type: ParamType::Text,
623 required: false,
624 description: "all, none, or comma-separated event types (default: all)".to_owned(),
625 }],
626 },
627 CommandInfo {
628 name: "set_event_filter".to_owned(),
629 description: "Alias for /subscribe_events — set event type filter".to_owned(),
630 usage: "/set_event_filter [filter]".to_owned(),
631 params: vec![ParamSchema {
632 name: "filter".to_owned(),
633 param_type: ParamType::Text,
634 required: false,
635 description: "all, none, or comma-separated event types (default: all)".to_owned(),
636 }],
637 },
638 CommandInfo {
639 name: "set_status".to_owned(),
640 description: "Set your presence status".to_owned(),
641 usage: "/set_status <status>".to_owned(),
642 params: vec![ParamSchema {
643 name: "status".to_owned(),
644 param_type: ParamType::Text,
645 required: false,
646 description: "Status text (omit to clear)".to_owned(),
647 }],
648 },
649 CommandInfo {
650 name: "subscriptions".to_owned(),
651 description: "List subscription tiers and event filters for this room".to_owned(),
652 usage: "/subscriptions".to_owned(),
653 params: vec![],
654 },
655 CommandInfo {
656 name: "help".to_owned(),
657 description: "List available commands or get help for a specific command".to_owned(),
658 usage: "/help [command]".to_owned(),
659 params: vec![ParamSchema {
660 name: "command".to_owned(),
661 param_type: ParamType::Text,
662 required: false,
663 description: "Command name to get help for".to_owned(),
664 }],
665 },
666 ]
667}
668
669pub fn all_known_commands() -> Vec<CommandInfo> {
674 let mut cmds = builtin_command_infos();
675 cmds.extend(queue::QueuePlugin::default_commands());
676 cmds.extend(stats::StatsPlugin.commands());
677 cmds.extend(taskboard::TaskboardPlugin::default_commands());
678 cmds
679}
680
681#[cfg(test)]
684mod tests {
685 use super::*;
686
687 struct DummyPlugin {
688 name: &'static str,
689 cmd: &'static str,
690 }
691
692 impl Plugin for DummyPlugin {
693 fn name(&self) -> &str {
694 self.name
695 }
696
697 fn commands(&self) -> Vec<CommandInfo> {
698 vec![CommandInfo {
699 name: self.cmd.to_owned(),
700 description: "dummy".to_owned(),
701 usage: format!("/{}", self.cmd),
702 params: vec![],
703 }]
704 }
705
706 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
707 Box::pin(async { Ok(PluginResult::Reply("dummy".to_owned())) })
708 }
709 }
710
711 #[test]
712 fn registry_register_and_resolve() {
713 let mut reg = PluginRegistry::new();
714 reg.register(Box::new(DummyPlugin {
715 name: "test",
716 cmd: "foo",
717 }))
718 .unwrap();
719 assert!(reg.resolve("foo").is_some());
720 assert!(reg.resolve("bar").is_none());
721 }
722
723 #[test]
724 fn registry_rejects_reserved_command() {
725 let mut reg = PluginRegistry::new();
726 let result = reg.register(Box::new(DummyPlugin {
727 name: "bad",
728 cmd: "kick",
729 }));
730 assert!(result.is_err());
731 let err = result.unwrap_err().to_string();
732 assert!(err.contains("reserved by built-in"));
733 }
734
735 #[test]
736 fn registry_rejects_duplicate_command() {
737 let mut reg = PluginRegistry::new();
738 reg.register(Box::new(DummyPlugin {
739 name: "first",
740 cmd: "foo",
741 }))
742 .unwrap();
743 let result = reg.register(Box::new(DummyPlugin {
744 name: "second",
745 cmd: "foo",
746 }));
747 assert!(result.is_err());
748 let err = result.unwrap_err().to_string();
749 assert!(err.contains("already registered by 'first'"));
750 }
751
752 #[test]
753 fn registry_all_commands_lists_everything() {
754 let mut reg = PluginRegistry::new();
755 reg.register(Box::new(DummyPlugin {
756 name: "a",
757 cmd: "alpha",
758 }))
759 .unwrap();
760 reg.register(Box::new(DummyPlugin {
761 name: "b",
762 cmd: "beta",
763 }))
764 .unwrap();
765 let cmds = reg.all_commands();
766 let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
767 assert!(names.contains(&"alpha"));
768 assert!(names.contains(&"beta"));
769 assert_eq!(names.len(), 2);
770 }
771
772 #[test]
773 fn registry_completions_for_returns_choice_values() {
774 let mut reg = PluginRegistry::new();
775 reg.register(Box::new({
776 struct CompPlugin;
777 impl Plugin for CompPlugin {
778 fn name(&self) -> &str {
779 "comp"
780 }
781 fn commands(&self) -> Vec<CommandInfo> {
782 vec![CommandInfo {
783 name: "test".to_owned(),
784 description: "test".to_owned(),
785 usage: "/test".to_owned(),
786 params: vec![ParamSchema {
787 name: "count".to_owned(),
788 param_type: ParamType::Choice(vec!["10".to_owned(), "20".to_owned()]),
789 required: false,
790 description: "Number of items".to_owned(),
791 }],
792 }]
793 }
794 fn handle(
795 &self,
796 _ctx: CommandContext,
797 ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
798 Box::pin(async { Ok(PluginResult::Handled) })
799 }
800 }
801 CompPlugin
802 }))
803 .unwrap();
804 let completions = reg.completions_for("test", 0);
805 assert_eq!(completions, vec!["10", "20"]);
806 assert!(reg.completions_for("test", 1).is_empty());
807 assert!(reg.completions_for("nonexistent", 0).is_empty());
808 }
809
810 #[test]
811 fn registry_completions_for_non_choice_returns_empty() {
812 let mut reg = PluginRegistry::new();
813 reg.register(Box::new({
814 struct TextPlugin;
815 impl Plugin for TextPlugin {
816 fn name(&self) -> &str {
817 "text"
818 }
819 fn commands(&self) -> Vec<CommandInfo> {
820 vec![CommandInfo {
821 name: "echo".to_owned(),
822 description: "echo".to_owned(),
823 usage: "/echo".to_owned(),
824 params: vec![ParamSchema {
825 name: "msg".to_owned(),
826 param_type: ParamType::Text,
827 required: true,
828 description: "Message".to_owned(),
829 }],
830 }]
831 }
832 fn handle(
833 &self,
834 _ctx: CommandContext,
835 ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
836 Box::pin(async { Ok(PluginResult::Handled) })
837 }
838 }
839 TextPlugin
840 }))
841 .unwrap();
842 assert!(reg.completions_for("echo", 0).is_empty());
844 }
845
846 #[test]
847 fn registry_rejects_all_reserved_commands() {
848 for &reserved in RESERVED_COMMANDS {
849 let mut reg = PluginRegistry::new();
850 let result = reg.register(Box::new(DummyPlugin {
851 name: "bad",
852 cmd: reserved,
853 }));
854 assert!(
855 result.is_err(),
856 "should reject reserved command '{reserved}'"
857 );
858 }
859 }
860
861 #[test]
864 fn param_type_choice_equality() {
865 let a = ParamType::Choice(vec!["x".to_owned(), "y".to_owned()]);
866 let b = ParamType::Choice(vec!["x".to_owned(), "y".to_owned()]);
867 assert_eq!(a, b);
868 let c = ParamType::Choice(vec!["x".to_owned()]);
869 assert_ne!(a, c);
870 }
871
872 #[test]
873 fn param_type_number_equality() {
874 let a = ParamType::Number {
875 min: Some(1),
876 max: Some(100),
877 };
878 let b = ParamType::Number {
879 min: Some(1),
880 max: Some(100),
881 };
882 assert_eq!(a, b);
883 let c = ParamType::Number {
884 min: None,
885 max: None,
886 };
887 assert_ne!(a, c);
888 }
889
890 #[test]
891 fn param_type_variants_are_distinct() {
892 assert_ne!(ParamType::Text, ParamType::Username);
893 assert_ne!(
894 ParamType::Text,
895 ParamType::Number {
896 min: None,
897 max: None
898 }
899 );
900 assert_ne!(ParamType::Text, ParamType::Choice(vec!["a".to_owned()]));
901 }
902
903 #[test]
906 fn builtin_command_infos_covers_all_expected_commands() {
907 let cmds = builtin_command_infos();
908 let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
909 for expected in &[
910 "dm",
911 "reply",
912 "who",
913 "help",
914 "info",
915 "kick",
916 "reauth",
917 "clear-tokens",
918 "exit",
919 "clear",
920 "room-info",
921 "set_status",
922 "subscribe",
923 "set_subscription",
924 "unsubscribe",
925 "subscribe_events",
926 "set_event_filter",
927 "subscriptions",
928 ] {
929 assert!(
930 names.contains(expected),
931 "missing built-in command: {expected}"
932 );
933 }
934 }
935
936 #[test]
937 fn builtin_command_infos_dm_has_username_param() {
938 let cmds = builtin_command_infos();
939 let dm = cmds.iter().find(|c| c.name == "dm").unwrap();
940 assert_eq!(dm.params.len(), 2);
941 assert_eq!(dm.params[0].param_type, ParamType::Username);
942 assert!(dm.params[0].required);
943 assert_eq!(dm.params[1].param_type, ParamType::Text);
944 }
945
946 #[test]
947 fn builtin_command_infos_kick_has_username_param() {
948 let cmds = builtin_command_infos();
949 let kick = cmds.iter().find(|c| c.name == "kick").unwrap();
950 assert_eq!(kick.params.len(), 1);
951 assert_eq!(kick.params[0].param_type, ParamType::Username);
952 assert!(kick.params[0].required);
953 }
954
955 #[test]
956 fn builtin_command_infos_who_has_no_params() {
957 let cmds = builtin_command_infos();
958 let who = cmds.iter().find(|c| c.name == "who").unwrap();
959 assert!(who.params.is_empty());
960 }
961
962 #[test]
965 fn all_known_commands_includes_builtins_and_plugins() {
966 let cmds = all_known_commands();
967 let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
968 assert!(names.contains(&"dm"));
970 assert!(names.contains(&"who"));
971 assert!(names.contains(&"kick"));
972 assert!(names.contains(&"help"));
973 assert!(names.contains(&"stats"));
975 }
976
977 #[test]
978 fn all_known_commands_no_duplicates() {
979 let cmds = all_known_commands();
980 let mut names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
981 let before = names.len();
982 names.sort();
983 names.dedup();
984 assert_eq!(before, names.len(), "duplicate command names found");
985 }
986
987 #[tokio::test]
988 async fn history_reader_filters_dms() {
989 let tmp = tempfile::NamedTempFile::new().unwrap();
990 let path = tmp.path();
991
992 let dm = crate::message::make_dm("r", "alice", "bob", "secret");
994 let public = crate::message::make_message("r", "carol", "hello all");
995 history::append(path, &dm).await.unwrap();
996 history::append(path, &public).await.unwrap();
997
998 let reader_alice = HistoryReader::new(path, "alice");
1000 let msgs = reader_alice.all().await.unwrap();
1001 assert_eq!(msgs.len(), 2);
1002
1003 let reader_carol = HistoryReader::new(path, "carol");
1005 let msgs = reader_carol.all().await.unwrap();
1006 assert_eq!(msgs.len(), 1);
1007 assert_eq!(msgs[0].user(), "carol");
1008 }
1009
1010 #[tokio::test]
1011 async fn history_reader_tail_and_count() {
1012 let tmp = tempfile::NamedTempFile::new().unwrap();
1013 let path = tmp.path();
1014
1015 for i in 0..5 {
1016 history::append(
1017 path,
1018 &crate::message::make_message("r", "u", format!("msg {i}")),
1019 )
1020 .await
1021 .unwrap();
1022 }
1023
1024 let reader = HistoryReader::new(path, "u");
1025 assert_eq!(reader.count().await.unwrap(), 5);
1026
1027 let tail = reader.tail(3).await.unwrap();
1028 assert_eq!(tail.len(), 3);
1029 }
1030
1031 #[tokio::test]
1032 async fn history_reader_since() {
1033 let tmp = tempfile::NamedTempFile::new().unwrap();
1034 let path = tmp.path();
1035
1036 let msg1 = crate::message::make_message("r", "u", "first");
1037 let msg2 = crate::message::make_message("r", "u", "second");
1038 let msg3 = crate::message::make_message("r", "u", "third");
1039 let id1 = msg1.id().to_owned();
1040 history::append(path, &msg1).await.unwrap();
1041 history::append(path, &msg2).await.unwrap();
1042 history::append(path, &msg3).await.unwrap();
1043
1044 let reader = HistoryReader::new(path, "u");
1045 let since = reader.since(&id1).await.unwrap();
1046 assert_eq!(since.len(), 2);
1047 }
1048
1049 struct MinimalPlugin;
1054
1055 impl Plugin for MinimalPlugin {
1056 fn name(&self) -> &str {
1057 "minimal"
1058 }
1059
1060 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
1061 Box::pin(async { Ok(PluginResult::Handled) })
1062 }
1063 }
1065
1066 #[test]
1067 fn default_commands_returns_empty_vec() {
1068 assert!(MinimalPlugin.commands().is_empty());
1069 }
1070
1071 #[test]
1072 fn default_lifecycle_hooks_are_noop() {
1073 MinimalPlugin.on_user_join("alice");
1075 MinimalPlugin.on_user_leave("alice");
1076 }
1077
1078 #[test]
1079 fn registry_notify_join_calls_all_plugins() {
1080 use std::sync::{Arc, Mutex};
1081
1082 struct TrackingPlugin {
1083 joined: Arc<Mutex<Vec<String>>>,
1084 left: Arc<Mutex<Vec<String>>>,
1085 }
1086
1087 impl Plugin for TrackingPlugin {
1088 fn name(&self) -> &str {
1089 "tracking"
1090 }
1091
1092 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
1093 Box::pin(async { Ok(PluginResult::Handled) })
1094 }
1095
1096 fn on_user_join(&self, user: &str) {
1097 self.joined.lock().unwrap().push(user.to_owned());
1098 }
1099
1100 fn on_user_leave(&self, user: &str) {
1101 self.left.lock().unwrap().push(user.to_owned());
1102 }
1103 }
1104
1105 let joined = Arc::new(Mutex::new(Vec::<String>::new()));
1106 let left = Arc::new(Mutex::new(Vec::<String>::new()));
1107 let mut reg = PluginRegistry::new();
1108 reg.register(Box::new(TrackingPlugin {
1109 joined: joined.clone(),
1110 left: left.clone(),
1111 }))
1112 .unwrap();
1113
1114 reg.notify_join("alice");
1115 reg.notify_join("bob");
1116 reg.notify_leave("alice");
1117
1118 assert_eq!(*joined.lock().unwrap(), vec!["alice", "bob"]);
1119 assert_eq!(*left.lock().unwrap(), vec!["alice"]);
1120 }
1121
1122 #[test]
1123 fn registry_notify_join_empty_registry_is_noop() {
1124 let reg = PluginRegistry::new();
1125 reg.notify_join("alice");
1127 reg.notify_leave("alice");
1128 }
1129
1130 #[test]
1131 fn minimal_plugin_can_be_registered_without_commands() {
1132 let mut reg = PluginRegistry::new();
1133 reg.register(Box::new(MinimalPlugin)).unwrap();
1136 assert_eq!(reg.all_commands().len(), 0);
1138 }
1139}