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 "set_subscription",
345 "unsubscribe",
346 "subscriptions",
347];
348
349pub struct PluginRegistry {
351 plugins: Vec<Box<dyn Plugin>>,
352 command_map: HashMap<String, usize>,
354}
355
356impl PluginRegistry {
357 pub fn new() -> Self {
358 Self {
359 plugins: Vec::new(),
360 command_map: HashMap::new(),
361 }
362 }
363
364 pub fn register(&mut self, plugin: Box<dyn Plugin>) -> anyhow::Result<()> {
367 let idx = self.plugins.len();
368 for cmd in plugin.commands() {
369 if RESERVED_COMMANDS.contains(&cmd.name.as_str()) {
370 anyhow::bail!(
371 "plugin '{}' cannot register command '{}': reserved by built-in",
372 plugin.name(),
373 cmd.name
374 );
375 }
376 if let Some(&existing_idx) = self.command_map.get(&cmd.name) {
377 anyhow::bail!(
378 "plugin '{}' cannot register command '{}': already registered by '{}'",
379 plugin.name(),
380 cmd.name,
381 self.plugins[existing_idx].name()
382 );
383 }
384 self.command_map.insert(cmd.name.clone(), idx);
385 }
386 self.plugins.push(plugin);
387 Ok(())
388 }
389
390 pub fn resolve(&self, command: &str) -> Option<&dyn Plugin> {
392 self.command_map
393 .get(command)
394 .map(|&idx| self.plugins[idx].as_ref())
395 }
396
397 pub fn all_commands(&self) -> Vec<CommandInfo> {
399 self.plugins.iter().flat_map(|p| p.commands()).collect()
400 }
401
402 pub fn notify_join(&self, user: &str) {
406 for plugin in &self.plugins {
407 plugin.on_user_join(user);
408 }
409 }
410
411 pub fn notify_leave(&self, user: &str) {
415 for plugin in &self.plugins {
416 plugin.on_user_leave(user);
417 }
418 }
419
420 pub fn completions_for(&self, command: &str, arg_pos: usize) -> Vec<String> {
426 self.all_commands()
427 .iter()
428 .find(|c| c.name == command)
429 .and_then(|c| c.params.get(arg_pos))
430 .map(|p| match &p.param_type {
431 ParamType::Choice(values) => values.clone(),
432 _ => vec![],
433 })
434 .unwrap_or_default()
435 }
436}
437
438impl Default for PluginRegistry {
439 fn default() -> Self {
440 Self::new()
441 }
442}
443
444pub fn builtin_command_infos() -> Vec<CommandInfo> {
450 vec![
451 CommandInfo {
452 name: "dm".to_owned(),
453 description: "Send a private message".to_owned(),
454 usage: "/dm <user> <message>".to_owned(),
455 params: vec![
456 ParamSchema {
457 name: "user".to_owned(),
458 param_type: ParamType::Username,
459 required: true,
460 description: "Recipient username".to_owned(),
461 },
462 ParamSchema {
463 name: "message".to_owned(),
464 param_type: ParamType::Text,
465 required: true,
466 description: "Message content".to_owned(),
467 },
468 ],
469 },
470 CommandInfo {
471 name: "claim".to_owned(),
472 description: "Claim a task".to_owned(),
473 usage: "/claim <task>".to_owned(),
474 params: vec![ParamSchema {
475 name: "task".to_owned(),
476 param_type: ParamType::Text,
477 required: true,
478 description: "Task description".to_owned(),
479 }],
480 },
481 CommandInfo {
482 name: "unclaim".to_owned(),
483 description: "Release your current task claim".to_owned(),
484 usage: "/unclaim".to_owned(),
485 params: vec![],
486 },
487 CommandInfo {
488 name: "claimed".to_owned(),
489 description: "Show the task claim board".to_owned(),
490 usage: "/claimed".to_owned(),
491 params: vec![],
492 },
493 CommandInfo {
494 name: "reply".to_owned(),
495 description: "Reply to a message".to_owned(),
496 usage: "/reply <id> <message>".to_owned(),
497 params: vec![
498 ParamSchema {
499 name: "id".to_owned(),
500 param_type: ParamType::Text,
501 required: true,
502 description: "Message ID to reply to".to_owned(),
503 },
504 ParamSchema {
505 name: "message".to_owned(),
506 param_type: ParamType::Text,
507 required: true,
508 description: "Reply content".to_owned(),
509 },
510 ],
511 },
512 CommandInfo {
513 name: "set_status".to_owned(),
514 description: "Set your presence status".to_owned(),
515 usage: "/set_status <status>".to_owned(),
516 params: vec![ParamSchema {
517 name: "status".to_owned(),
518 param_type: ParamType::Text,
519 required: false,
520 description: "Status text (omit to clear)".to_owned(),
521 }],
522 },
523 CommandInfo {
524 name: "who".to_owned(),
525 description: "List users in the room".to_owned(),
526 usage: "/who".to_owned(),
527 params: vec![],
528 },
529 CommandInfo {
530 name: "kick".to_owned(),
531 description: "Kick a user from the room".to_owned(),
532 usage: "/kick <user>".to_owned(),
533 params: vec![ParamSchema {
534 name: "user".to_owned(),
535 param_type: ParamType::Username,
536 required: true,
537 description: "User to kick (host only)".to_owned(),
538 }],
539 },
540 CommandInfo {
541 name: "reauth".to_owned(),
542 description: "Invalidate a user's token".to_owned(),
543 usage: "/reauth <user>".to_owned(),
544 params: vec![ParamSchema {
545 name: "user".to_owned(),
546 param_type: ParamType::Username,
547 required: true,
548 description: "User to reauth (host only)".to_owned(),
549 }],
550 },
551 CommandInfo {
552 name: "clear-tokens".to_owned(),
553 description: "Revoke all session tokens".to_owned(),
554 usage: "/clear-tokens".to_owned(),
555 params: vec![],
556 },
557 CommandInfo {
558 name: "exit".to_owned(),
559 description: "Shut down the broker".to_owned(),
560 usage: "/exit".to_owned(),
561 params: vec![],
562 },
563 CommandInfo {
564 name: "clear".to_owned(),
565 description: "Clear the room history".to_owned(),
566 usage: "/clear".to_owned(),
567 params: vec![],
568 },
569 CommandInfo {
570 name: "room-info".to_owned(),
571 description: "Show room visibility, config, and member count".to_owned(),
572 usage: "/room-info".to_owned(),
573 params: vec![],
574 },
575 CommandInfo {
576 name: "subscribe".to_owned(),
577 description: "Subscribe to this room".to_owned(),
578 usage: "/subscribe [tier]".to_owned(),
579 params: vec![ParamSchema {
580 name: "tier".to_owned(),
581 param_type: ParamType::Choice(vec!["full".to_owned(), "mentions_only".to_owned()]),
582 required: false,
583 description: "Subscription tier (default: full)".to_owned(),
584 }],
585 },
586 CommandInfo {
587 name: "set_subscription".to_owned(),
588 description: "Alias for /subscribe — set subscription tier for this room".to_owned(),
589 usage: "/set_subscription [tier]".to_owned(),
590 params: vec![ParamSchema {
591 name: "tier".to_owned(),
592 param_type: ParamType::Choice(vec!["full".to_owned(), "mentions_only".to_owned()]),
593 required: false,
594 description: "Subscription tier (default: full)".to_owned(),
595 }],
596 },
597 CommandInfo {
598 name: "unsubscribe".to_owned(),
599 description: "Unsubscribe from this room".to_owned(),
600 usage: "/unsubscribe".to_owned(),
601 params: vec![],
602 },
603 CommandInfo {
604 name: "subscriptions".to_owned(),
605 description: "List subscription tiers for this room".to_owned(),
606 usage: "/subscriptions".to_owned(),
607 params: vec![],
608 },
609 ]
610}
611
612pub fn all_known_commands() -> Vec<CommandInfo> {
617 let mut cmds = builtin_command_infos();
618 cmds.extend(help::HelpPlugin.commands());
619 cmds.extend(stats::StatsPlugin.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 "set_status",
858 "who",
859 "kick",
860 "reauth",
861 "clear-tokens",
862 "exit",
863 "clear",
864 "room-info",
865 "subscribe",
866 "set_subscription",
867 "unsubscribe",
868 "subscriptions",
869 ] {
870 assert!(
871 names.contains(expected),
872 "missing built-in command: {expected}"
873 );
874 }
875 }
876
877 #[test]
878 fn builtin_command_infos_dm_has_username_param() {
879 let cmds = builtin_command_infos();
880 let dm = cmds.iter().find(|c| c.name == "dm").unwrap();
881 assert_eq!(dm.params.len(), 2);
882 assert_eq!(dm.params[0].param_type, ParamType::Username);
883 assert!(dm.params[0].required);
884 assert_eq!(dm.params[1].param_type, ParamType::Text);
885 }
886
887 #[test]
888 fn builtin_command_infos_kick_has_username_param() {
889 let cmds = builtin_command_infos();
890 let kick = cmds.iter().find(|c| c.name == "kick").unwrap();
891 assert_eq!(kick.params.len(), 1);
892 assert_eq!(kick.params[0].param_type, ParamType::Username);
893 assert!(kick.params[0].required);
894 }
895
896 #[test]
897 fn builtin_command_infos_set_status_is_optional() {
898 let cmds = builtin_command_infos();
899 let ss = cmds.iter().find(|c| c.name == "set_status").unwrap();
900 assert_eq!(ss.params.len(), 1);
901 assert!(!ss.params[0].required);
902 }
903
904 #[test]
905 fn builtin_command_infos_who_has_no_params() {
906 let cmds = builtin_command_infos();
907 let who = cmds.iter().find(|c| c.name == "who").unwrap();
908 assert!(who.params.is_empty());
909 }
910
911 #[test]
914 fn all_known_commands_includes_builtins_and_plugins() {
915 let cmds = all_known_commands();
916 let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
917 assert!(names.contains(&"dm"));
919 assert!(names.contains(&"who"));
920 assert!(names.contains(&"kick"));
921 assert!(names.contains(&"help"));
923 assert!(names.contains(&"stats"));
924 }
925
926 #[test]
927 fn all_known_commands_no_duplicates() {
928 let cmds = all_known_commands();
929 let mut names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
930 let before = names.len();
931 names.sort();
932 names.dedup();
933 assert_eq!(before, names.len(), "duplicate command names found");
934 }
935
936 #[tokio::test]
937 async fn history_reader_filters_dms() {
938 let tmp = tempfile::NamedTempFile::new().unwrap();
939 let path = tmp.path();
940
941 let dm = crate::message::make_dm("r", "alice", "bob", "secret");
943 let public = crate::message::make_message("r", "carol", "hello all");
944 history::append(path, &dm).await.unwrap();
945 history::append(path, &public).await.unwrap();
946
947 let reader_alice = HistoryReader::new(path, "alice");
949 let msgs = reader_alice.all().await.unwrap();
950 assert_eq!(msgs.len(), 2);
951
952 let reader_carol = HistoryReader::new(path, "carol");
954 let msgs = reader_carol.all().await.unwrap();
955 assert_eq!(msgs.len(), 1);
956 assert_eq!(msgs[0].user(), "carol");
957 }
958
959 #[tokio::test]
960 async fn history_reader_tail_and_count() {
961 let tmp = tempfile::NamedTempFile::new().unwrap();
962 let path = tmp.path();
963
964 for i in 0..5 {
965 history::append(
966 path,
967 &crate::message::make_message("r", "u", format!("msg {i}")),
968 )
969 .await
970 .unwrap();
971 }
972
973 let reader = HistoryReader::new(path, "u");
974 assert_eq!(reader.count().await.unwrap(), 5);
975
976 let tail = reader.tail(3).await.unwrap();
977 assert_eq!(tail.len(), 3);
978 }
979
980 #[tokio::test]
981 async fn history_reader_since() {
982 let tmp = tempfile::NamedTempFile::new().unwrap();
983 let path = tmp.path();
984
985 let msg1 = crate::message::make_message("r", "u", "first");
986 let msg2 = crate::message::make_message("r", "u", "second");
987 let msg3 = crate::message::make_message("r", "u", "third");
988 let id1 = msg1.id().to_owned();
989 history::append(path, &msg1).await.unwrap();
990 history::append(path, &msg2).await.unwrap();
991 history::append(path, &msg3).await.unwrap();
992
993 let reader = HistoryReader::new(path, "u");
994 let since = reader.since(&id1).await.unwrap();
995 assert_eq!(since.len(), 2);
996 }
997
998 struct MinimalPlugin;
1003
1004 impl Plugin for MinimalPlugin {
1005 fn name(&self) -> &str {
1006 "minimal"
1007 }
1008
1009 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
1010 Box::pin(async { Ok(PluginResult::Handled) })
1011 }
1012 }
1014
1015 #[test]
1016 fn default_commands_returns_empty_vec() {
1017 assert!(MinimalPlugin.commands().is_empty());
1018 }
1019
1020 #[test]
1021 fn default_lifecycle_hooks_are_noop() {
1022 MinimalPlugin.on_user_join("alice");
1024 MinimalPlugin.on_user_leave("alice");
1025 }
1026
1027 #[test]
1028 fn registry_notify_join_calls_all_plugins() {
1029 use std::sync::{Arc, Mutex};
1030
1031 struct TrackingPlugin {
1032 joined: Arc<Mutex<Vec<String>>>,
1033 left: Arc<Mutex<Vec<String>>>,
1034 }
1035
1036 impl Plugin for TrackingPlugin {
1037 fn name(&self) -> &str {
1038 "tracking"
1039 }
1040
1041 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
1042 Box::pin(async { Ok(PluginResult::Handled) })
1043 }
1044
1045 fn on_user_join(&self, user: &str) {
1046 self.joined.lock().unwrap().push(user.to_owned());
1047 }
1048
1049 fn on_user_leave(&self, user: &str) {
1050 self.left.lock().unwrap().push(user.to_owned());
1051 }
1052 }
1053
1054 let joined = Arc::new(Mutex::new(Vec::<String>::new()));
1055 let left = Arc::new(Mutex::new(Vec::<String>::new()));
1056 let mut reg = PluginRegistry::new();
1057 reg.register(Box::new(TrackingPlugin {
1058 joined: joined.clone(),
1059 left: left.clone(),
1060 }))
1061 .unwrap();
1062
1063 reg.notify_join("alice");
1064 reg.notify_join("bob");
1065 reg.notify_leave("alice");
1066
1067 assert_eq!(*joined.lock().unwrap(), vec!["alice", "bob"]);
1068 assert_eq!(*left.lock().unwrap(), vec!["alice"]);
1069 }
1070
1071 #[test]
1072 fn registry_notify_join_empty_registry_is_noop() {
1073 let reg = PluginRegistry::new();
1074 reg.notify_join("alice");
1076 reg.notify_leave("alice");
1077 }
1078
1079 #[test]
1080 fn minimal_plugin_can_be_registered_without_commands() {
1081 let mut reg = PluginRegistry::new();
1082 reg.register(Box::new(MinimalPlugin)).unwrap();
1085 assert_eq!(reg.all_commands().len(), 0);
1087 }
1088}