Skip to main content

room_cli/plugin/
stats.rs

1use std::collections::HashMap;
2
3use crate::message::Message;
4
5use super::{BoxFuture, CommandContext, CommandInfo, Completion, Plugin, PluginResult};
6
7/// Example `/stats` plugin. Shows a statistical summary of recent chat
8/// activity: message count, participant count, time range, most active user.
9///
10/// `/summarise` is reserved for LLM-powered v2 — the core binary does not
11/// depend on any LLM SDK.
12pub 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    // Count messages per user (only Message variants, not join/leave/system)
60    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        // 5 events total, but only 2 participants (alice, bob) in chat messages
113        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        // Write some messages
142        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        // The broadcast should have sent a message
180        let broadcast_msg = rx.try_recv().unwrap();
181        assert!(broadcast_msg.contains("stats"));
182        assert!(broadcast_msg.contains("alice"));
183    }
184}