1pub mod help;
2pub mod stats;
3
4use std::{
5 collections::HashMap,
6 future::Future,
7 path::{Path, PathBuf},
8 pin::Pin,
9 sync::{
10 atomic::{AtomicU64, Ordering},
11 Arc,
12 },
13};
14
15use chrono::{DateTime, Utc};
16
17use crate::{
18 broker::{
19 fanout::broadcast_and_persist,
20 state::{ClientMap, StatusMap},
21 },
22 history,
23 message::{make_system, Message},
24};
25
26pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
28
29pub trait Plugin: Send + Sync {
44 fn name(&self) -> &str;
46
47 fn commands(&self) -> Vec<CommandInfo> {
53 vec![]
54 }
55
56 fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>>;
61
62 fn on_user_join(&self, _user: &str) {}
67
68 fn on_user_leave(&self, _user: &str) {}
73}
74
75#[derive(Debug, Clone)]
79pub struct CommandInfo {
80 pub name: String,
82 pub description: String,
84 pub usage: String,
86 pub params: Vec<ParamSchema>,
88}
89
90#[derive(Debug, Clone)]
95pub struct ParamSchema {
96 pub name: String,
98 pub param_type: ParamType,
100 pub required: bool,
102 pub description: String,
104}
105
106#[derive(Debug, Clone, PartialEq)]
108pub enum ParamType {
109 Text,
111 Choice(Vec<String>),
113 Username,
115 Number { min: Option<i64>, max: Option<i64> },
117}
118
119pub struct CommandContext {
123 pub command: String,
125 pub params: Vec<String>,
127 pub sender: String,
129 pub room_id: String,
131 pub message_id: String,
133 pub timestamp: DateTime<Utc>,
135 pub history: HistoryReader,
137 pub writer: ChatWriter,
139 pub metadata: RoomMetadata,
141 pub available_commands: Vec<CommandInfo>,
144}
145
146pub enum PluginResult {
150 Reply(String),
152 Broadcast(String),
154 Handled,
156}
157
158pub struct HistoryReader {
165 chat_path: PathBuf,
166 viewer: String,
167}
168
169impl HistoryReader {
170 pub(crate) fn new(chat_path: &Path, viewer: &str) -> Self {
171 Self {
172 chat_path: chat_path.to_owned(),
173 viewer: viewer.to_owned(),
174 }
175 }
176
177 pub async fn all(&self) -> anyhow::Result<Vec<Message>> {
179 let all = history::load(&self.chat_path).await?;
180 Ok(self.filter_dms(all))
181 }
182
183 pub async fn tail(&self, n: usize) -> anyhow::Result<Vec<Message>> {
185 let all = history::tail(&self.chat_path, n).await?;
186 Ok(self.filter_dms(all))
187 }
188
189 pub async fn since(&self, message_id: &str) -> anyhow::Result<Vec<Message>> {
191 let all = history::load(&self.chat_path).await?;
192 let start = all
193 .iter()
194 .position(|m| m.id() == message_id)
195 .map(|i| i + 1)
196 .unwrap_or(0);
197 Ok(self.filter_dms(all[start..].to_vec()))
198 }
199
200 pub async fn count(&self) -> anyhow::Result<usize> {
202 let all = history::load(&self.chat_path).await?;
203 Ok(all.len())
204 }
205
206 fn filter_dms(&self, messages: Vec<Message>) -> Vec<Message> {
207 messages
208 .into_iter()
209 .filter(|m| match m {
210 Message::DirectMessage { user, to, .. } => {
211 user == &self.viewer || to == &self.viewer
212 }
213 _ => true,
214 })
215 .collect()
216 }
217}
218
219pub struct ChatWriter {
226 clients: ClientMap,
227 chat_path: Arc<PathBuf>,
228 room_id: Arc<String>,
229 seq_counter: Arc<AtomicU64>,
230 identity: String,
232}
233
234impl ChatWriter {
235 pub(crate) fn new(
236 clients: &ClientMap,
237 chat_path: &Arc<PathBuf>,
238 room_id: &Arc<String>,
239 seq_counter: &Arc<AtomicU64>,
240 plugin_name: &str,
241 ) -> Self {
242 Self {
243 clients: clients.clone(),
244 chat_path: chat_path.clone(),
245 room_id: room_id.clone(),
246 seq_counter: seq_counter.clone(),
247 identity: format!("plugin:{plugin_name}"),
248 }
249 }
250
251 pub async fn broadcast(&self, content: &str) -> anyhow::Result<()> {
253 let msg = make_system(&self.room_id, &self.identity, content);
254 broadcast_and_persist(&msg, &self.clients, &self.chat_path, &self.seq_counter).await?;
255 Ok(())
256 }
257
258 pub async fn reply_to(&self, username: &str, content: &str) -> anyhow::Result<()> {
260 let msg = make_system(&self.room_id, &self.identity, content);
261 let seq = self.seq_counter.fetch_add(1, Ordering::SeqCst) + 1;
262 let mut msg = msg;
263 msg.set_seq(seq);
264 history::append(&self.chat_path, &msg).await?;
265
266 let line = format!("{}\n", serde_json::to_string(&msg)?);
267 let map = self.clients.lock().await;
268 for (uname, tx) in map.values() {
269 if uname == username {
270 let _ = tx.send(line.clone());
271 }
272 }
273 Ok(())
274 }
275}
276
277pub struct RoomMetadata {
281 pub online_users: Vec<UserInfo>,
283 pub host: Option<String>,
285 pub message_count: usize,
287}
288
289pub struct UserInfo {
291 pub username: String,
292 pub status: String,
293}
294
295impl RoomMetadata {
296 pub(crate) async fn snapshot(
297 status_map: &StatusMap,
298 host_user: &Arc<tokio::sync::Mutex<Option<String>>>,
299 chat_path: &Path,
300 ) -> Self {
301 let map = status_map.lock().await;
302 let online_users: Vec<UserInfo> = map
303 .iter()
304 .map(|(u, s)| UserInfo {
305 username: u.clone(),
306 status: s.clone(),
307 })
308 .collect();
309 drop(map);
310
311 let host = host_user.lock().await.clone();
312
313 let message_count = history::load(chat_path)
314 .await
315 .map(|msgs| msgs.len())
316 .unwrap_or(0);
317
318 Self {
319 online_users,
320 host,
321 message_count,
322 }
323 }
324}
325
326const RESERVED_COMMANDS: &[&str] = &[
330 "set_status",
331 "who",
332 "kick",
333 "reauth",
334 "clear-tokens",
335 "dm",
336 "claim",
337 "unclaim",
338 "claimed",
339 "reply",
340 "room-info",
341 "exit",
342 "clear",
343 "subscribe",
344 "unsubscribe",
345 "subscriptions",
346];
347
348pub struct PluginRegistry {
350 plugins: Vec<Box<dyn Plugin>>,
351 command_map: HashMap<String, usize>,
353}
354
355impl PluginRegistry {
356 pub fn new() -> Self {
357 Self {
358 plugins: Vec::new(),
359 command_map: HashMap::new(),
360 }
361 }
362
363 pub fn register(&mut self, plugin: Box<dyn Plugin>) -> anyhow::Result<()> {
366 let idx = self.plugins.len();
367 for cmd in plugin.commands() {
368 if RESERVED_COMMANDS.contains(&cmd.name.as_str()) {
369 anyhow::bail!(
370 "plugin '{}' cannot register command '{}': reserved by built-in",
371 plugin.name(),
372 cmd.name
373 );
374 }
375 if let Some(&existing_idx) = self.command_map.get(&cmd.name) {
376 anyhow::bail!(
377 "plugin '{}' cannot register command '{}': already registered by '{}'",
378 plugin.name(),
379 cmd.name,
380 self.plugins[existing_idx].name()
381 );
382 }
383 self.command_map.insert(cmd.name.clone(), idx);
384 }
385 self.plugins.push(plugin);
386 Ok(())
387 }
388
389 pub fn resolve(&self, command: &str) -> Option<&dyn Plugin> {
391 self.command_map
392 .get(command)
393 .map(|&idx| self.plugins[idx].as_ref())
394 }
395
396 pub fn all_commands(&self) -> Vec<CommandInfo> {
398 self.plugins.iter().flat_map(|p| p.commands()).collect()
399 }
400
401 pub fn notify_join(&self, user: &str) {
405 for plugin in &self.plugins {
406 plugin.on_user_join(user);
407 }
408 }
409
410 pub fn notify_leave(&self, user: &str) {
414 for plugin in &self.plugins {
415 plugin.on_user_leave(user);
416 }
417 }
418
419 pub fn completions_for(&self, command: &str, arg_pos: usize) -> Vec<String> {
425 self.all_commands()
426 .iter()
427 .find(|c| c.name == command)
428 .and_then(|c| c.params.get(arg_pos))
429 .map(|p| match &p.param_type {
430 ParamType::Choice(values) => values.clone(),
431 _ => vec![],
432 })
433 .unwrap_or_default()
434 }
435}
436
437impl Default for PluginRegistry {
438 fn default() -> Self {
439 Self::new()
440 }
441}
442
443pub fn builtin_command_infos() -> Vec<CommandInfo> {
449 vec![
450 CommandInfo {
451 name: "dm".to_owned(),
452 description: "Send a private message".to_owned(),
453 usage: "/dm <user> <message>".to_owned(),
454 params: vec![
455 ParamSchema {
456 name: "user".to_owned(),
457 param_type: ParamType::Username,
458 required: true,
459 description: "Recipient username".to_owned(),
460 },
461 ParamSchema {
462 name: "message".to_owned(),
463 param_type: ParamType::Text,
464 required: true,
465 description: "Message content".to_owned(),
466 },
467 ],
468 },
469 CommandInfo {
470 name: "claim".to_owned(),
471 description: "Claim a task".to_owned(),
472 usage: "/claim <task>".to_owned(),
473 params: vec![ParamSchema {
474 name: "task".to_owned(),
475 param_type: ParamType::Text,
476 required: true,
477 description: "Task description".to_owned(),
478 }],
479 },
480 CommandInfo {
481 name: "unclaim".to_owned(),
482 description: "Release your current task claim".to_owned(),
483 usage: "/unclaim".to_owned(),
484 params: vec![],
485 },
486 CommandInfo {
487 name: "claimed".to_owned(),
488 description: "Show the task claim board".to_owned(),
489 usage: "/claimed".to_owned(),
490 params: vec![],
491 },
492 CommandInfo {
493 name: "reply".to_owned(),
494 description: "Reply to a message".to_owned(),
495 usage: "/reply <id> <message>".to_owned(),
496 params: vec![
497 ParamSchema {
498 name: "id".to_owned(),
499 param_type: ParamType::Text,
500 required: true,
501 description: "Message ID to reply to".to_owned(),
502 },
503 ParamSchema {
504 name: "message".to_owned(),
505 param_type: ParamType::Text,
506 required: true,
507 description: "Reply content".to_owned(),
508 },
509 ],
510 },
511 CommandInfo {
512 name: "set_status".to_owned(),
513 description: "Set your presence status".to_owned(),
514 usage: "/set_status <status>".to_owned(),
515 params: vec![ParamSchema {
516 name: "status".to_owned(),
517 param_type: ParamType::Text,
518 required: false,
519 description: "Status text (omit to clear)".to_owned(),
520 }],
521 },
522 CommandInfo {
523 name: "who".to_owned(),
524 description: "List users in the room".to_owned(),
525 usage: "/who".to_owned(),
526 params: vec![],
527 },
528 CommandInfo {
529 name: "kick".to_owned(),
530 description: "Kick a user from the room".to_owned(),
531 usage: "/kick <user>".to_owned(),
532 params: vec![ParamSchema {
533 name: "user".to_owned(),
534 param_type: ParamType::Username,
535 required: true,
536 description: "User to kick (host only)".to_owned(),
537 }],
538 },
539 CommandInfo {
540 name: "reauth".to_owned(),
541 description: "Invalidate a user's token".to_owned(),
542 usage: "/reauth <user>".to_owned(),
543 params: vec![ParamSchema {
544 name: "user".to_owned(),
545 param_type: ParamType::Username,
546 required: true,
547 description: "User to reauth (host only)".to_owned(),
548 }],
549 },
550 CommandInfo {
551 name: "clear-tokens".to_owned(),
552 description: "Revoke all session tokens".to_owned(),
553 usage: "/clear-tokens".to_owned(),
554 params: vec![],
555 },
556 CommandInfo {
557 name: "exit".to_owned(),
558 description: "Shut down the broker".to_owned(),
559 usage: "/exit".to_owned(),
560 params: vec![],
561 },
562 CommandInfo {
563 name: "clear".to_owned(),
564 description: "Clear the room history".to_owned(),
565 usage: "/clear".to_owned(),
566 params: vec![],
567 },
568 CommandInfo {
569 name: "room-info".to_owned(),
570 description: "Show room visibility, config, and member count".to_owned(),
571 usage: "/room-info".to_owned(),
572 params: vec![],
573 },
574 CommandInfo {
575 name: "subscribe".to_owned(),
576 description: "Subscribe to this room".to_owned(),
577 usage: "/subscribe [tier]".to_owned(),
578 params: vec![ParamSchema {
579 name: "tier".to_owned(),
580 param_type: ParamType::Choice(vec!["full".to_owned(), "mentions_only".to_owned()]),
581 required: false,
582 description: "Subscription tier (default: full)".to_owned(),
583 }],
584 },
585 CommandInfo {
586 name: "unsubscribe".to_owned(),
587 description: "Unsubscribe from this room".to_owned(),
588 usage: "/unsubscribe".to_owned(),
589 params: vec![],
590 },
591 CommandInfo {
592 name: "subscriptions".to_owned(),
593 description: "List subscription tiers for this room".to_owned(),
594 usage: "/subscriptions".to_owned(),
595 params: vec![],
596 },
597 ]
598}
599
600pub fn all_known_commands() -> Vec<CommandInfo> {
605 let mut cmds = builtin_command_infos();
606 cmds.extend(help::HelpPlugin.commands());
607 cmds.extend(stats::StatsPlugin.commands());
608 cmds
609}
610
611#[cfg(test)]
614mod tests {
615 use super::*;
616
617 struct DummyPlugin {
618 name: &'static str,
619 cmd: &'static str,
620 }
621
622 impl Plugin for DummyPlugin {
623 fn name(&self) -> &str {
624 self.name
625 }
626
627 fn commands(&self) -> Vec<CommandInfo> {
628 vec![CommandInfo {
629 name: self.cmd.to_owned(),
630 description: "dummy".to_owned(),
631 usage: format!("/{}", self.cmd),
632 params: vec![],
633 }]
634 }
635
636 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
637 Box::pin(async { Ok(PluginResult::Reply("dummy".to_owned())) })
638 }
639 }
640
641 #[test]
642 fn registry_register_and_resolve() {
643 let mut reg = PluginRegistry::new();
644 reg.register(Box::new(DummyPlugin {
645 name: "test",
646 cmd: "foo",
647 }))
648 .unwrap();
649 assert!(reg.resolve("foo").is_some());
650 assert!(reg.resolve("bar").is_none());
651 }
652
653 #[test]
654 fn registry_rejects_reserved_command() {
655 let mut reg = PluginRegistry::new();
656 let result = reg.register(Box::new(DummyPlugin {
657 name: "bad",
658 cmd: "kick",
659 }));
660 assert!(result.is_err());
661 let err = result.unwrap_err().to_string();
662 assert!(err.contains("reserved by built-in"));
663 }
664
665 #[test]
666 fn registry_rejects_duplicate_command() {
667 let mut reg = PluginRegistry::new();
668 reg.register(Box::new(DummyPlugin {
669 name: "first",
670 cmd: "foo",
671 }))
672 .unwrap();
673 let result = reg.register(Box::new(DummyPlugin {
674 name: "second",
675 cmd: "foo",
676 }));
677 assert!(result.is_err());
678 let err = result.unwrap_err().to_string();
679 assert!(err.contains("already registered by 'first'"));
680 }
681
682 #[test]
683 fn registry_all_commands_lists_everything() {
684 let mut reg = PluginRegistry::new();
685 reg.register(Box::new(DummyPlugin {
686 name: "a",
687 cmd: "alpha",
688 }))
689 .unwrap();
690 reg.register(Box::new(DummyPlugin {
691 name: "b",
692 cmd: "beta",
693 }))
694 .unwrap();
695 let cmds = reg.all_commands();
696 let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
697 assert!(names.contains(&"alpha"));
698 assert!(names.contains(&"beta"));
699 assert_eq!(names.len(), 2);
700 }
701
702 #[test]
703 fn registry_completions_for_returns_choice_values() {
704 let mut reg = PluginRegistry::new();
705 reg.register(Box::new({
706 struct CompPlugin;
707 impl Plugin for CompPlugin {
708 fn name(&self) -> &str {
709 "comp"
710 }
711 fn commands(&self) -> Vec<CommandInfo> {
712 vec![CommandInfo {
713 name: "test".to_owned(),
714 description: "test".to_owned(),
715 usage: "/test".to_owned(),
716 params: vec![ParamSchema {
717 name: "count".to_owned(),
718 param_type: ParamType::Choice(vec!["10".to_owned(), "20".to_owned()]),
719 required: false,
720 description: "Number of items".to_owned(),
721 }],
722 }]
723 }
724 fn handle(
725 &self,
726 _ctx: CommandContext,
727 ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
728 Box::pin(async { Ok(PluginResult::Handled) })
729 }
730 }
731 CompPlugin
732 }))
733 .unwrap();
734 let completions = reg.completions_for("test", 0);
735 assert_eq!(completions, vec!["10", "20"]);
736 assert!(reg.completions_for("test", 1).is_empty());
737 assert!(reg.completions_for("nonexistent", 0).is_empty());
738 }
739
740 #[test]
741 fn registry_completions_for_non_choice_returns_empty() {
742 let mut reg = PluginRegistry::new();
743 reg.register(Box::new({
744 struct TextPlugin;
745 impl Plugin for TextPlugin {
746 fn name(&self) -> &str {
747 "text"
748 }
749 fn commands(&self) -> Vec<CommandInfo> {
750 vec![CommandInfo {
751 name: "echo".to_owned(),
752 description: "echo".to_owned(),
753 usage: "/echo".to_owned(),
754 params: vec![ParamSchema {
755 name: "msg".to_owned(),
756 param_type: ParamType::Text,
757 required: true,
758 description: "Message".to_owned(),
759 }],
760 }]
761 }
762 fn handle(
763 &self,
764 _ctx: CommandContext,
765 ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
766 Box::pin(async { Ok(PluginResult::Handled) })
767 }
768 }
769 TextPlugin
770 }))
771 .unwrap();
772 assert!(reg.completions_for("echo", 0).is_empty());
774 }
775
776 #[test]
777 fn registry_rejects_all_reserved_commands() {
778 for &reserved in RESERVED_COMMANDS {
779 let mut reg = PluginRegistry::new();
780 let result = reg.register(Box::new(DummyPlugin {
781 name: "bad",
782 cmd: reserved,
783 }));
784 assert!(
785 result.is_err(),
786 "should reject reserved command '{reserved}'"
787 );
788 }
789 }
790
791 #[test]
794 fn param_type_choice_equality() {
795 let a = ParamType::Choice(vec!["x".to_owned(), "y".to_owned()]);
796 let b = ParamType::Choice(vec!["x".to_owned(), "y".to_owned()]);
797 assert_eq!(a, b);
798 let c = ParamType::Choice(vec!["x".to_owned()]);
799 assert_ne!(a, c);
800 }
801
802 #[test]
803 fn param_type_number_equality() {
804 let a = ParamType::Number {
805 min: Some(1),
806 max: Some(100),
807 };
808 let b = ParamType::Number {
809 min: Some(1),
810 max: Some(100),
811 };
812 assert_eq!(a, b);
813 let c = ParamType::Number {
814 min: None,
815 max: None,
816 };
817 assert_ne!(a, c);
818 }
819
820 #[test]
821 fn param_type_variants_are_distinct() {
822 assert_ne!(ParamType::Text, ParamType::Username);
823 assert_ne!(
824 ParamType::Text,
825 ParamType::Number {
826 min: None,
827 max: None
828 }
829 );
830 assert_ne!(ParamType::Text, ParamType::Choice(vec!["a".to_owned()]));
831 }
832
833 #[test]
836 fn builtin_command_infos_covers_all_expected_commands() {
837 let cmds = builtin_command_infos();
838 let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
839 for expected in &[
840 "dm",
841 "claim",
842 "unclaim",
843 "claimed",
844 "reply",
845 "set_status",
846 "who",
847 "kick",
848 "reauth",
849 "clear-tokens",
850 "exit",
851 "clear",
852 "room-info",
853 "subscribe",
854 "unsubscribe",
855 "subscriptions",
856 ] {
857 assert!(
858 names.contains(expected),
859 "missing built-in command: {expected}"
860 );
861 }
862 }
863
864 #[test]
865 fn builtin_command_infos_dm_has_username_param() {
866 let cmds = builtin_command_infos();
867 let dm = cmds.iter().find(|c| c.name == "dm").unwrap();
868 assert_eq!(dm.params.len(), 2);
869 assert_eq!(dm.params[0].param_type, ParamType::Username);
870 assert!(dm.params[0].required);
871 assert_eq!(dm.params[1].param_type, ParamType::Text);
872 }
873
874 #[test]
875 fn builtin_command_infos_kick_has_username_param() {
876 let cmds = builtin_command_infos();
877 let kick = cmds.iter().find(|c| c.name == "kick").unwrap();
878 assert_eq!(kick.params.len(), 1);
879 assert_eq!(kick.params[0].param_type, ParamType::Username);
880 assert!(kick.params[0].required);
881 }
882
883 #[test]
884 fn builtin_command_infos_set_status_is_optional() {
885 let cmds = builtin_command_infos();
886 let ss = cmds.iter().find(|c| c.name == "set_status").unwrap();
887 assert_eq!(ss.params.len(), 1);
888 assert!(!ss.params[0].required);
889 }
890
891 #[test]
892 fn builtin_command_infos_who_has_no_params() {
893 let cmds = builtin_command_infos();
894 let who = cmds.iter().find(|c| c.name == "who").unwrap();
895 assert!(who.params.is_empty());
896 }
897
898 #[test]
901 fn all_known_commands_includes_builtins_and_plugins() {
902 let cmds = all_known_commands();
903 let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
904 assert!(names.contains(&"dm"));
906 assert!(names.contains(&"who"));
907 assert!(names.contains(&"kick"));
908 assert!(names.contains(&"help"));
910 assert!(names.contains(&"stats"));
911 }
912
913 #[test]
914 fn all_known_commands_no_duplicates() {
915 let cmds = all_known_commands();
916 let mut names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
917 let before = names.len();
918 names.sort();
919 names.dedup();
920 assert_eq!(before, names.len(), "duplicate command names found");
921 }
922
923 #[tokio::test]
924 async fn history_reader_filters_dms() {
925 let tmp = tempfile::NamedTempFile::new().unwrap();
926 let path = tmp.path();
927
928 let dm = crate::message::make_dm("r", "alice", "bob", "secret");
930 let public = crate::message::make_message("r", "carol", "hello all");
931 history::append(path, &dm).await.unwrap();
932 history::append(path, &public).await.unwrap();
933
934 let reader_alice = HistoryReader::new(path, "alice");
936 let msgs = reader_alice.all().await.unwrap();
937 assert_eq!(msgs.len(), 2);
938
939 let reader_carol = HistoryReader::new(path, "carol");
941 let msgs = reader_carol.all().await.unwrap();
942 assert_eq!(msgs.len(), 1);
943 assert_eq!(msgs[0].user(), "carol");
944 }
945
946 #[tokio::test]
947 async fn history_reader_tail_and_count() {
948 let tmp = tempfile::NamedTempFile::new().unwrap();
949 let path = tmp.path();
950
951 for i in 0..5 {
952 history::append(
953 path,
954 &crate::message::make_message("r", "u", format!("msg {i}")),
955 )
956 .await
957 .unwrap();
958 }
959
960 let reader = HistoryReader::new(path, "u");
961 assert_eq!(reader.count().await.unwrap(), 5);
962
963 let tail = reader.tail(3).await.unwrap();
964 assert_eq!(tail.len(), 3);
965 }
966
967 #[tokio::test]
968 async fn history_reader_since() {
969 let tmp = tempfile::NamedTempFile::new().unwrap();
970 let path = tmp.path();
971
972 let msg1 = crate::message::make_message("r", "u", "first");
973 let msg2 = crate::message::make_message("r", "u", "second");
974 let msg3 = crate::message::make_message("r", "u", "third");
975 let id1 = msg1.id().to_owned();
976 history::append(path, &msg1).await.unwrap();
977 history::append(path, &msg2).await.unwrap();
978 history::append(path, &msg3).await.unwrap();
979
980 let reader = HistoryReader::new(path, "u");
981 let since = reader.since(&id1).await.unwrap();
982 assert_eq!(since.len(), 2);
983 }
984
985 struct MinimalPlugin;
990
991 impl Plugin for MinimalPlugin {
992 fn name(&self) -> &str {
993 "minimal"
994 }
995
996 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
997 Box::pin(async { Ok(PluginResult::Handled) })
998 }
999 }
1001
1002 #[test]
1003 fn default_commands_returns_empty_vec() {
1004 assert!(MinimalPlugin.commands().is_empty());
1005 }
1006
1007 #[test]
1008 fn default_lifecycle_hooks_are_noop() {
1009 MinimalPlugin.on_user_join("alice");
1011 MinimalPlugin.on_user_leave("alice");
1012 }
1013
1014 #[test]
1015 fn registry_notify_join_calls_all_plugins() {
1016 use std::sync::{Arc, Mutex};
1017
1018 struct TrackingPlugin {
1019 joined: Arc<Mutex<Vec<String>>>,
1020 left: Arc<Mutex<Vec<String>>>,
1021 }
1022
1023 impl Plugin for TrackingPlugin {
1024 fn name(&self) -> &str {
1025 "tracking"
1026 }
1027
1028 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
1029 Box::pin(async { Ok(PluginResult::Handled) })
1030 }
1031
1032 fn on_user_join(&self, user: &str) {
1033 self.joined.lock().unwrap().push(user.to_owned());
1034 }
1035
1036 fn on_user_leave(&self, user: &str) {
1037 self.left.lock().unwrap().push(user.to_owned());
1038 }
1039 }
1040
1041 let joined = Arc::new(Mutex::new(Vec::<String>::new()));
1042 let left = Arc::new(Mutex::new(Vec::<String>::new()));
1043 let mut reg = PluginRegistry::new();
1044 reg.register(Box::new(TrackingPlugin {
1045 joined: joined.clone(),
1046 left: left.clone(),
1047 }))
1048 .unwrap();
1049
1050 reg.notify_join("alice");
1051 reg.notify_join("bob");
1052 reg.notify_leave("alice");
1053
1054 assert_eq!(*joined.lock().unwrap(), vec!["alice", "bob"]);
1055 assert_eq!(*left.lock().unwrap(), vec!["alice"]);
1056 }
1057
1058 #[test]
1059 fn registry_notify_join_empty_registry_is_noop() {
1060 let reg = PluginRegistry::new();
1061 reg.notify_join("alice");
1063 reg.notify_leave("alice");
1064 }
1065
1066 #[test]
1067 fn minimal_plugin_can_be_registered_without_commands() {
1068 let mut reg = PluginRegistry::new();
1069 reg.register(Box::new(MinimalPlugin)).unwrap();
1072 assert_eq!(reg.all_commands().len(), 0);
1074 }
1075}