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