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