1use std::collections::HashMap;
2
3use crate::message::Message;
4
5use super::{BoxFuture, CommandContext, CommandInfo, Completion, Plugin, PluginResult};
6
7pub struct StatsPlugin;
13
14impl Plugin for StatsPlugin {
15 fn name(&self) -> &str {
16 "stats"
17 }
18
19 fn commands(&self) -> Vec<CommandInfo> {
20 vec![CommandInfo {
21 name: "stats".to_owned(),
22 description: "Show statistical summary of recent chat activity".to_owned(),
23 usage: "/stats [last N messages, default 50]".to_owned(),
24 completions: vec![Completion {
25 position: 0,
26 values: vec![
27 "10".to_owned(),
28 "25".to_owned(),
29 "50".to_owned(),
30 "100".to_owned(),
31 ],
32 }],
33 }]
34 }
35
36 fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
37 Box::pin(async move {
38 let n: usize = ctx
39 .params
40 .first()
41 .and_then(|s| s.parse().ok())
42 .unwrap_or(50);
43
44 let messages = ctx.history.tail(n).await?;
45 let summary = build_summary(&messages);
46 ctx.writer.broadcast(&summary).await?;
47 Ok(PluginResult::Handled)
48 })
49 }
50}
51
52fn build_summary(messages: &[Message]) -> String {
53 if messages.is_empty() {
54 return "stats: no messages in the requested range".to_owned();
55 }
56
57 let total = messages.len();
58
59 let mut user_counts: HashMap<&str, usize> = HashMap::new();
61 for msg in messages {
62 if matches!(msg, Message::Message { .. } | Message::DirectMessage { .. }) {
63 *user_counts.entry(msg.user()).or_insert(0) += 1;
64 }
65 }
66 let participant_count = user_counts.len();
67
68 let most_active = user_counts
69 .iter()
70 .max_by_key(|(_, count)| *count)
71 .map(|(user, count)| format!("{user} ({count} msgs)"))
72 .unwrap_or_else(|| "none".to_owned());
73
74 let time_range = match (messages.first(), messages.last()) {
75 (Some(first), Some(last)) => {
76 format!(
77 "{} to {}",
78 first.ts().format("%H:%M UTC"),
79 last.ts().format("%H:%M UTC")
80 )
81 }
82 _ => "unknown".to_owned(),
83 };
84
85 format!(
86 "stats (last {total} events): {participant_count} participants, \
87 most active: {most_active}, time range: {time_range}"
88 )
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use crate::message::{make_join, make_message, make_system};
95
96 #[test]
97 fn build_summary_empty() {
98 let summary = build_summary(&[]);
99 assert!(summary.contains("no messages"));
100 }
101
102 #[test]
103 fn build_summary_counts_only_chat_messages() {
104 let msgs = vec![
105 make_join("r", "alice"),
106 make_message("r", "alice", "hello"),
107 make_message("r", "bob", "hi"),
108 make_message("r", "alice", "how are you"),
109 make_system("r", "broker", "system notice"),
110 ];
111 let summary = build_summary(&msgs);
112 assert!(summary.contains("5 events"));
114 assert!(summary.contains("2 participants"));
115 assert!(summary.contains("alice (2 msgs)"));
116 }
117
118 #[test]
119 fn build_summary_single_user() {
120 let msgs = vec![
121 make_message("r", "alice", "one"),
122 make_message("r", "alice", "two"),
123 ];
124 let summary = build_summary(&msgs);
125 assert!(summary.contains("1 participants"));
126 assert!(summary.contains("alice (2 msgs)"));
127 }
128
129 #[tokio::test]
130 async fn stats_plugin_broadcasts_summary() {
131 use crate::plugin::{ChatWriter, HistoryReader, RoomMetadata, UserInfo};
132 use chrono::Utc;
133 use std::collections::HashMap;
134 use std::sync::{atomic::AtomicU64, Arc};
135 use tempfile::NamedTempFile;
136 use tokio::sync::{broadcast, Mutex};
137
138 let tmp = NamedTempFile::new().unwrap();
139 let path = tmp.path();
140
141 for i in 0..3 {
143 crate::history::append(path, &make_message("r", "alice", format!("msg {i}")))
144 .await
145 .unwrap();
146 }
147
148 let (tx, mut rx) = broadcast::channel::<String>(64);
149 let mut client_map = HashMap::new();
150 client_map.insert(1u64, ("alice".to_owned(), tx));
151 let clients = Arc::new(Mutex::new(client_map));
152 let chat_path = Arc::new(path.to_path_buf());
153 let room_id = Arc::new("r".to_owned());
154 let seq = Arc::new(AtomicU64::new(0));
155
156 let ctx = super::super::CommandContext {
157 command: "stats".to_owned(),
158 params: vec!["10".to_owned()],
159 sender: "alice".to_owned(),
160 room_id: "r".to_owned(),
161 message_id: "msg-1".to_owned(),
162 timestamp: Utc::now(),
163 history: HistoryReader::new(path, "alice"),
164 writer: ChatWriter::new(&clients, &chat_path, &room_id, &seq, "stats"),
165 metadata: RoomMetadata {
166 online_users: vec![UserInfo {
167 username: "alice".to_owned(),
168 status: String::new(),
169 }],
170 host: Some("alice".to_owned()),
171 message_count: 3,
172 },
173 available_commands: vec![],
174 };
175
176 let result = StatsPlugin.handle(ctx).await.unwrap();
177 assert!(matches!(result, PluginResult::Handled));
178
179 let broadcast_msg = rx.try_recv().unwrap();
181 assert!(broadcast_msg.contains("stats"));
182 assert!(broadcast_msg.contains("alice"));
183 }
184}