Skip to main content

ws_kit/
room.rs

1//! Room-based group messaging.
2//!
3//! [`RoomManager`] owns a [`DashMap`] of [`Room`]s. Each [`Room`] is an
4//! isolated broadcast domain with a participant roster keyed by connection
5//! id (`u32`) mapping to a display name (`String`).
6
7use std::sync::Arc;
8
9use dashmap::DashMap;
10use tokio::sync::broadcast;
11
12use crate::config::WsConfig;
13use crate::error::WsError;
14
15/// A single broadcast room with a participant roster.
16pub struct Room {
17    /// Room identifier.
18    pub id: String,
19    tx: broadcast::Sender<String>,
20    /// Participant roster: connection id -> display name.
21    participants: DashMap<u32, String>,
22}
23
24impl std::fmt::Debug for Room {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        f.debug_struct("Room")
27            .field("id", &self.id)
28            .field("participants", &self.participant_count())
29            .field("receiver_count", &self.receiver_count())
30            .finish()
31    }
32}
33
34impl Room {
35    /// Create a new room with the given id and broadcast capacity.
36    pub fn new(id: impl Into<String>, capacity: usize) -> Self {
37        let (tx, _) = broadcast::channel(capacity);
38        Self {
39            id: id.into(),
40            tx,
41            participants: DashMap::new(),
42        }
43    }
44
45    /// Subscribe to this room's messages.
46    pub fn subscribe(&self) -> broadcast::Receiver<String> {
47        self.tx.subscribe()
48    }
49
50    /// Broadcast a text message to all participants.
51    pub fn broadcast(&self, msg: String) -> Result<usize, WsError> {
52        self.tx.send(msg).map_err(|_| WsError::BroadcastFull)
53    }
54
55    /// Add a participant (or refresh its display name on re-join).
56    pub fn join(&self, participant_id: u32, name: String) {
57        self.participants.insert(participant_id, name);
58    }
59
60    /// Remove a participant. Returns true if it was present.
61    pub fn leave(&self, participant_id: u32) -> bool {
62        self.participants.remove(&participant_id).is_some()
63    }
64
65    /// Check if participant is in room.
66    pub fn contains(&self, participant_id: u32) -> bool {
67        self.participants.contains_key(&participant_id)
68    }
69
70    /// Snapshot of `(participant_id, name)` pairs.
71    pub fn participants(&self) -> Vec<(u32, String)> {
72        self.participants
73            .iter()
74            .map(|r| (*r.key(), r.value().clone()))
75            .collect()
76    }
77
78    /// Snapshot of participant display names.
79    pub fn participant_names(&self) -> Vec<String> {
80        self.participants.iter().map(|r| r.value().clone()).collect()
81    }
82
83    /// Number of tracked participants.
84    pub fn participant_count(&self) -> usize {
85        self.participants.len()
86    }
87
88    /// Number of active broadcast receivers.
89    pub fn receiver_count(&self) -> usize {
90        self.tx.receiver_count()
91    }
92
93    /// Returns true if no participants and no receivers.
94    pub fn is_empty(&self) -> bool {
95        self.participants.is_empty() && self.tx.receiver_count() == 0
96    }
97
98    /// Internal sender.
99    pub fn sender(&self) -> &broadcast::Sender<String> {
100        &self.tx
101    }
102}
103
104/// Manages a collection of [`Room`]s.
105#[derive(Debug)]
106pub struct RoomManager {
107    rooms: DashMap<String, Arc<Room>>,
108    default_capacity: usize,
109}
110
111impl Default for RoomManager {
112    fn default() -> Self {
113        Self::new()
114    }
115}
116
117impl RoomManager {
118    /// Create an empty manager with default capacity (1024).
119    pub fn new() -> Self {
120        Self {
121            rooms: DashMap::new(),
122            default_capacity: 1024,
123        }
124    }
125
126    /// Create an empty manager whose default capacity comes from [`WsConfig`].
127    pub fn with_config(config: &WsConfig) -> Self {
128        Self {
129            rooms: DashMap::new(),
130            default_capacity: config.broadcast_capacity,
131        }
132    }
133
134    /// Get an existing room or create a new one with the manager's default capacity.
135    pub fn get_or_create(&self, room_id: &str) -> Arc<Room> {
136        self.get_or_create_with_capacity(room_id, self.default_capacity)
137    }
138
139    /// Get an existing room or create with custom capacity.
140    pub fn get_or_create_with_capacity(&self, room_id: &str, capacity: usize) -> Arc<Room> {
141        if let Some(r) = self.rooms.get(room_id) {
142            return Arc::clone(&r);
143        }
144        let room = Arc::new(Room::new(room_id, capacity));
145        // Insert-or-get to handle race.
146        let entry = self
147            .rooms
148            .entry(room_id.to_string())
149            .or_insert_with(|| Arc::clone(&room));
150        Arc::clone(entry.value())
151    }
152
153    /// Get a room if it exists.
154    pub fn get(&self, room_id: &str) -> Option<Arc<Room>> {
155        self.rooms.get(room_id).map(|r| Arc::clone(&r))
156    }
157
158    /// Remove a room by id, returning it if present.
159    pub fn remove(&self, room_id: &str) -> Option<Arc<Room>> {
160        self.rooms.remove(room_id).map(|(_, v)| v)
161    }
162
163    /// Number of rooms.
164    pub fn room_count(&self) -> usize {
165        self.rooms.len()
166    }
167
168    /// Total participants across all rooms.
169    pub fn total_participants(&self) -> usize {
170        self.rooms.iter().map(|e| e.value().participant_count()).sum()
171    }
172
173    /// Remove all empty rooms. Returns number removed.
174    pub fn cleanup(&self) -> usize {
175        let mut to_remove = Vec::new();
176        for entry in self.rooms.iter() {
177            if entry.value().is_empty() {
178                to_remove.push(entry.key().clone());
179            }
180        }
181        let n = to_remove.len();
182        for k in to_remove {
183            self.rooms.remove(&k);
184        }
185        n
186    }
187
188    /// Remove all rooms with zero participants (regardless of receivers).
189    /// Returns number removed.
190    pub fn cleanup_empty(&self) -> usize {
191        let to_remove: Vec<String> = self
192            .rooms
193            .iter()
194            .filter(|e| e.value().participant_count() == 0)
195            .map(|e| e.key().clone())
196            .collect();
197        let n = to_remove.len();
198        for k in to_remove {
199            self.rooms.remove(&k);
200        }
201        n
202    }
203
204    /// List room ids.
205    pub fn room_ids(&self) -> Vec<String> {
206        self.rooms.iter().map(|e| e.key().clone()).collect()
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn name(s: &str) -> String {
215        s.to_string()
216    }
217
218    #[test]
219    fn room_join_leave() {
220        let room = Room::new("lobby", 16);
221        assert_eq!(room.participant_count(), 0);
222        room.join(1, name("alice"));
223        room.join(2, name("bob"));
224        assert_eq!(room.participant_count(), 2);
225        assert!(room.contains(1));
226        assert!(room.leave(1));
227        assert!(!room.contains(1));
228        assert_eq!(room.participant_count(), 1);
229    }
230
231    #[test]
232    fn room_rejoin_updates_name() {
233        let room = Room::new("lobby", 16);
234        room.join(1, name("alice"));
235        room.join(1, name("alice2"));
236        assert_eq!(room.participant_count(), 1);
237        assert_eq!(room.participant_names(), vec!["alice2".to_string()]);
238    }
239
240    #[test]
241    fn room_participant_snapshots() {
242        let room = Room::new("lobby", 16);
243        room.join(7, name("alice"));
244        room.join(3, name("bob"));
245        let mut pairs = room.participants();
246        pairs.sort();
247        assert_eq!(pairs, vec![(3, "bob".to_string()), (7, "alice".to_string())]);
248        let mut names = room.participant_names();
249        names.sort();
250        assert_eq!(names, vec!["alice".to_string(), "bob".to_string()]);
251        assert_eq!(room.participant_count(), 2);
252    }
253
254    #[tokio::test]
255    async fn room_broadcast() {
256        let room = Room::new("chat", 16);
257        let mut rx = room.subscribe();
258        let n = room.broadcast("hello".to_string()).unwrap();
259        assert_eq!(n, 1);
260        assert_eq!(rx.recv().await.unwrap(), "hello");
261    }
262
263    #[tokio::test]
264    async fn room_broadcast_no_receiver() {
265        let room = Room::new("empty", 16);
266        let err = room.broadcast("hi".to_string()).unwrap_err();
267        assert_eq!(err, WsError::BroadcastFull);
268    }
269
270    #[test]
271    fn manager_get_or_create_idempotent() {
272        let m = RoomManager::new();
273        let r1 = m.get_or_create("room1");
274        let r2 = m.get_or_create("room1");
275        assert!(Arc::ptr_eq(&r1, &r2));
276        assert_eq!(m.room_count(), 1);
277    }
278
279    #[test]
280    fn manager_with_config_capacity() {
281        let cfg = WsConfig::builder().broadcast_capacity(2048).build();
282        let m = RoomManager::with_config(&cfg);
283        let r = m.get_or_create("room1");
284        // Capacity is not directly observable; verify the room works and is shared.
285        let r2 = m.get_or_create("room1");
286        assert!(Arc::ptr_eq(&r, &r2));
287        assert_eq!(m.room_count(), 1);
288    }
289
290    #[test]
291    fn manager_cleanup_removes_empty() {
292        let m = RoomManager::new();
293        let _r = m.get_or_create("a");
294        // No participants and no receivers -> empty, should be cleaned.
295        assert_eq!(m.cleanup(), 1);
296        assert_eq!(m.room_count(), 0);
297
298        let r = m.get_or_create("b");
299        r.join(1, name("user1"));
300        assert_eq!(m.cleanup(), 0);
301        assert_eq!(m.room_count(), 1);
302    }
303
304    #[test]
305    fn manager_cleanup_empty_removes_zero_participant_rooms() {
306        let m = RoomManager::new();
307        let _a = m.get_or_create("a");
308        // No participants -> removed regardless of receivers.
309        assert_eq!(m.cleanup_empty(), 1);
310        assert_eq!(m.room_count(), 0);
311
312        // Participant present -> kept.
313        let a = m.get_or_create("a");
314        a.join(1, name("alice"));
315        assert_eq!(m.cleanup_empty(), 0);
316        assert_eq!(m.room_count(), 1);
317        let kept = m.get_or_create("a");
318        assert!(Arc::ptr_eq(&a, &kept));
319
320        a.leave(1);
321        assert_eq!(m.cleanup_empty(), 1);
322        assert_eq!(m.room_count(), 0);
323    }
324
325    #[test]
326    fn manager_total_participants() {
327        let m = RoomManager::new();
328        let r1 = m.get_or_create("r1");
329        let r2 = m.get_or_create("r2");
330        assert_eq!(m.total_participants(), 0);
331        r1.join(1, name("alice"));
332        r1.join(2, name("bob"));
333        r2.join(3, name("carol"));
334        assert_eq!(m.total_participants(), 3);
335        r2.leave(3);
336        assert_eq!(m.total_participants(), 2);
337    }
338
339    #[test]
340    fn manager_get_and_remove() {
341        let m = RoomManager::new();
342        assert!(m.get("nonexistent").is_none());
343        let r = m.get_or_create("x");
344        assert!(m.get("x").is_some());
345        let removed = m.remove("x").unwrap();
346        assert!(Arc::ptr_eq(&r, &removed));
347        assert!(m.get("x").is_none());
348    }
349
350    #[tokio::test]
351    async fn manager_broadcast_isolation() {
352        let m = RoomManager::new();
353        let r1 = m.get_or_create("r1");
354        let r2 = m.get_or_create("r2");
355        let mut rx1 = r1.subscribe();
356        let mut rx2 = r2.subscribe();
357        r1.broadcast("for r1".to_string()).unwrap();
358        assert_eq!(rx1.recv().await.unwrap(), "for r1");
359        // r2 should not receive r1's message
360        use tokio::time::{timeout, Duration};
361        let res = timeout(Duration::from_millis(10), rx2.recv()).await;
362        assert!(res.is_err());
363    }
364}