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
81            .iter()
82            .map(|r| r.value().clone())
83            .collect()
84    }
85
86    /// Number of tracked participants.
87    pub fn participant_count(&self) -> usize {
88        self.participants.len()
89    }
90
91    /// Number of active broadcast receivers.
92    pub fn receiver_count(&self) -> usize {
93        self.tx.receiver_count()
94    }
95
96    /// Returns true if no participants and no receivers.
97    pub fn is_empty(&self) -> bool {
98        self.participants.is_empty() && self.tx.receiver_count() == 0
99    }
100
101    /// Internal sender.
102    pub fn sender(&self) -> &broadcast::Sender<String> {
103        &self.tx
104    }
105}
106
107/// Manages a collection of [`Room`]s.
108#[derive(Debug)]
109pub struct RoomManager {
110    rooms: DashMap<String, Arc<Room>>,
111    default_capacity: usize,
112}
113
114impl Default for RoomManager {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120impl RoomManager {
121    /// Create an empty manager with default capacity (1024).
122    pub fn new() -> Self {
123        Self {
124            rooms: DashMap::new(),
125            default_capacity: 1024,
126        }
127    }
128
129    /// Create an empty manager whose default capacity comes from [`WsConfig`].
130    pub fn with_config(config: &WsConfig) -> Self {
131        Self {
132            rooms: DashMap::new(),
133            default_capacity: config.broadcast_capacity,
134        }
135    }
136
137    /// Get an existing room or create a new one with the manager's default capacity.
138    pub fn get_or_create(&self, room_id: &str) -> Arc<Room> {
139        self.get_or_create_with_capacity(room_id, self.default_capacity)
140    }
141
142    /// Get an existing room or create with custom capacity.
143    pub fn get_or_create_with_capacity(&self, room_id: &str, capacity: usize) -> Arc<Room> {
144        if let Some(r) = self.rooms.get(room_id) {
145            return Arc::clone(&r);
146        }
147        let room = Arc::new(Room::new(room_id, capacity));
148        // Insert-or-get to handle race.
149        let entry = self
150            .rooms
151            .entry(room_id.to_string())
152            .or_insert_with(|| Arc::clone(&room));
153        Arc::clone(entry.value())
154    }
155
156    /// Get a room if it exists.
157    pub fn get(&self, room_id: &str) -> Option<Arc<Room>> {
158        self.rooms.get(room_id).map(|r| Arc::clone(&r))
159    }
160
161    /// Remove a room by id, returning it if present.
162    pub fn remove(&self, room_id: &str) -> Option<Arc<Room>> {
163        self.rooms.remove(room_id).map(|(_, v)| v)
164    }
165
166    /// Number of rooms.
167    pub fn room_count(&self) -> usize {
168        self.rooms.len()
169    }
170
171    /// Total participants across all rooms.
172    pub fn total_participants(&self) -> usize {
173        self.rooms
174            .iter()
175            .map(|e| e.value().participant_count())
176            .sum()
177    }
178
179    /// Remove all empty rooms. Returns number removed.
180    pub fn cleanup(&self) -> usize {
181        let mut to_remove = Vec::new();
182        for entry in self.rooms.iter() {
183            if entry.value().is_empty() {
184                to_remove.push(entry.key().clone());
185            }
186        }
187        let n = to_remove.len();
188        for k in to_remove {
189            self.rooms.remove(&k);
190        }
191        n
192    }
193
194    /// Remove all rooms with zero participants (regardless of receivers).
195    /// Returns number removed.
196    pub fn cleanup_empty(&self) -> usize {
197        let to_remove: Vec<String> = self
198            .rooms
199            .iter()
200            .filter(|e| e.value().participant_count() == 0)
201            .map(|e| e.key().clone())
202            .collect();
203        let n = to_remove.len();
204        for k in to_remove {
205            self.rooms.remove(&k);
206        }
207        n
208    }
209
210    /// List room ids.
211    pub fn room_ids(&self) -> Vec<String> {
212        self.rooms.iter().map(|e| e.key().clone()).collect()
213    }
214}
215
216// Tests exercise failure paths and invariants directly; unwrap/expect,
217// slicing, and panicking asserts are acceptable here — violations
218// surface as test failures, not production panics.
219#[allow(
220    clippy::unwrap_used,
221    clippy::expect_used,
222    clippy::indexing_slicing,
223    clippy::panic
224)]
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn name(s: &str) -> String {
230        s.to_string()
231    }
232
233    #[test]
234    fn room_join_leave() {
235        let room = Room::new("lobby", 16);
236        assert_eq!(room.participant_count(), 0);
237        room.join(1, name("alice"));
238        room.join(2, name("bob"));
239        assert_eq!(room.participant_count(), 2);
240        assert!(room.contains(1));
241        assert!(room.leave(1));
242        assert!(!room.contains(1));
243        assert_eq!(room.participant_count(), 1);
244    }
245
246    #[test]
247    fn room_rejoin_updates_name() {
248        let room = Room::new("lobby", 16);
249        room.join(1, name("alice"));
250        room.join(1, name("alice2"));
251        assert_eq!(room.participant_count(), 1);
252        assert_eq!(room.participant_names(), vec!["alice2".to_string()]);
253    }
254
255    #[test]
256    fn room_participant_snapshots() {
257        let room = Room::new("lobby", 16);
258        room.join(7, name("alice"));
259        room.join(3, name("bob"));
260        let mut pairs = room.participants();
261        pairs.sort();
262        assert_eq!(
263            pairs,
264            vec![(3, "bob".to_string()), (7, "alice".to_string())]
265        );
266        let mut names = room.participant_names();
267        names.sort();
268        assert_eq!(names, vec!["alice".to_string(), "bob".to_string()]);
269        assert_eq!(room.participant_count(), 2);
270    }
271
272    #[tokio::test]
273    async fn room_broadcast() {
274        let room = Room::new("chat", 16);
275        let mut rx = room.subscribe();
276        let n = room.broadcast("hello".to_string()).unwrap();
277        assert_eq!(n, 1);
278        assert_eq!(rx.recv().await.unwrap(), "hello");
279    }
280
281    #[tokio::test]
282    async fn room_broadcast_no_receiver() {
283        let room = Room::new("empty", 16);
284        let err = room.broadcast("hi".to_string()).unwrap_err();
285        assert_eq!(err, WsError::BroadcastFull);
286    }
287
288    #[test]
289    fn manager_get_or_create_idempotent() {
290        let m = RoomManager::new();
291        let r1 = m.get_or_create("room1");
292        let r2 = m.get_or_create("room1");
293        assert!(Arc::ptr_eq(&r1, &r2));
294        assert_eq!(m.room_count(), 1);
295    }
296
297    #[test]
298    fn manager_with_config_capacity() {
299        let cfg = WsConfig::builder().broadcast_capacity(2048).build();
300        let m = RoomManager::with_config(&cfg);
301        let r = m.get_or_create("room1");
302        // Capacity is not directly observable; verify the room works and is shared.
303        let r2 = m.get_or_create("room1");
304        assert!(Arc::ptr_eq(&r, &r2));
305        assert_eq!(m.room_count(), 1);
306    }
307
308    #[test]
309    fn manager_cleanup_removes_empty() {
310        let m = RoomManager::new();
311        let _r = m.get_or_create("a");
312        // No participants and no receivers -> empty, should be cleaned.
313        assert_eq!(m.cleanup(), 1);
314        assert_eq!(m.room_count(), 0);
315
316        let r = m.get_or_create("b");
317        r.join(1, name("user1"));
318        assert_eq!(m.cleanup(), 0);
319        assert_eq!(m.room_count(), 1);
320    }
321
322    #[test]
323    fn manager_cleanup_empty_removes_zero_participant_rooms() {
324        let m = RoomManager::new();
325        let _a = m.get_or_create("a");
326        // No participants -> removed regardless of receivers.
327        assert_eq!(m.cleanup_empty(), 1);
328        assert_eq!(m.room_count(), 0);
329
330        // Participant present -> kept.
331        let a = m.get_or_create("a");
332        a.join(1, name("alice"));
333        assert_eq!(m.cleanup_empty(), 0);
334        assert_eq!(m.room_count(), 1);
335        let kept = m.get_or_create("a");
336        assert!(Arc::ptr_eq(&a, &kept));
337
338        a.leave(1);
339        assert_eq!(m.cleanup_empty(), 1);
340        assert_eq!(m.room_count(), 0);
341    }
342
343    #[test]
344    fn manager_total_participants() {
345        let m = RoomManager::new();
346        let r1 = m.get_or_create("r1");
347        let r2 = m.get_or_create("r2");
348        assert_eq!(m.total_participants(), 0);
349        r1.join(1, name("alice"));
350        r1.join(2, name("bob"));
351        r2.join(3, name("carol"));
352        assert_eq!(m.total_participants(), 3);
353        r2.leave(3);
354        assert_eq!(m.total_participants(), 2);
355    }
356
357    #[test]
358    fn manager_get_and_remove() {
359        let m = RoomManager::new();
360        assert!(m.get("nonexistent").is_none());
361        let r = m.get_or_create("x");
362        assert!(m.get("x").is_some());
363        let removed = m.remove("x").unwrap();
364        assert!(Arc::ptr_eq(&r, &removed));
365        assert!(m.get("x").is_none());
366    }
367
368    #[tokio::test]
369    async fn manager_broadcast_isolation() {
370        let m = RoomManager::new();
371        let r1 = m.get_or_create("r1");
372        let r2 = m.get_or_create("r2");
373        let mut rx1 = r1.subscribe();
374        let mut rx2 = r2.subscribe();
375        r1.broadcast("for r1".to_string()).unwrap();
376        assert_eq!(rx1.recv().await.unwrap(), "for r1");
377        // r2 should not receive r1's message
378        use tokio::time::{timeout, Duration};
379        let res = timeout(Duration::from_millis(10), rx2.recv()).await;
380        assert!(res.is_err());
381    }
382}