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 participant tracking.
5
6use std::sync::Arc;
7
8use dashmap::DashMap;
9use tokio::sync::broadcast;
10
11use crate::error::WsError;
12
13/// A single broadcast room.
14pub struct Room {
15    /// Room identifier.
16    pub id: String,
17    tx: broadcast::Sender<String>,
18    participants: DashMap<String, ()>,
19}
20
21impl std::fmt::Debug for Room {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        f.debug_struct("Room")
24            .field("id", &self.id)
25            .field("participants", &self.participant_count())
26            .field("receiver_count", &self.receiver_count())
27            .finish()
28    }
29}
30
31impl Room {
32    /// Create a new room with the given id and broadcast capacity.
33    pub fn new(id: impl Into<String>, capacity: usize) -> Self {
34        let (tx, _) = broadcast::channel(capacity);
35        Self {
36            id: id.into(),
37            tx,
38            participants: DashMap::new(),
39        }
40    }
41
42    /// Subscribe to this room's messages.
43    pub fn subscribe(&self) -> broadcast::Receiver<String> {
44        self.tx.subscribe()
45    }
46
47    /// Broadcast a text message to all participants.
48    pub fn broadcast(&self, msg: String) -> Result<usize, WsError> {
49        self.tx.send(msg).map_err(|_| WsError::BroadcastFull)
50    }
51
52    /// Add a participant.
53    pub fn join(&self, participant_id: impl Into<String>) {
54        self.participants.insert(participant_id.into(), ());
55    }
56
57    /// Remove a participant.
58    pub fn leave(&self, participant_id: &str) -> bool {
59        self.participants.remove(participant_id).is_some()
60    }
61
62    /// Check if participant is in room.
63    pub fn contains(&self, participant_id: &str) -> bool {
64        self.participants.contains_key(participant_id)
65    }
66
67    /// Number of tracked participants.
68    pub fn participant_count(&self) -> usize {
69        self.participants.len()
70    }
71
72    /// Number of active broadcast receivers.
73    pub fn receiver_count(&self) -> usize {
74        self.tx.receiver_count()
75    }
76
77    /// Returns true if no participants and no receivers.
78    pub fn is_empty(&self) -> bool {
79        self.participants.is_empty() && self.tx.receiver_count() == 0
80    }
81
82    /// Internal sender.
83    pub fn sender(&self) -> &broadcast::Sender<String> {
84        &self.tx
85    }
86}
87
88/// Manages a collection of [`Room`]s.
89#[derive(Debug, Default)]
90pub struct RoomManager {
91    rooms: DashMap<String, Arc<Room>>,
92}
93
94impl RoomManager {
95    /// Create an empty manager.
96    pub fn new() -> Self {
97        Self {
98            rooms: DashMap::new(),
99        }
100    }
101
102    /// Get an existing room or create a new one with default capacity (1024).
103    pub fn get_or_create(&self, room_id: &str) -> Arc<Room> {
104        if let Some(r) = self.rooms.get(room_id) {
105            return Arc::clone(&r);
106        }
107        let room = Arc::new(Room::new(room_id, 1024));
108        // Insert-or-get to handle race.
109        let entry = self.rooms.entry(room_id.to_string()).or_insert_with(|| Arc::clone(&room));
110        Arc::clone(entry.value())
111    }
112
113    /// Get an existing room or create with custom capacity.
114    pub fn get_or_create_with_capacity(&self, room_id: &str, capacity: usize) -> Arc<Room> {
115        if let Some(r) = self.rooms.get(room_id) {
116            return Arc::clone(&r);
117        }
118        let room = Arc::new(Room::new(room_id, capacity));
119        let entry = self.rooms.entry(room_id.to_string()).or_insert_with(|| Arc::clone(&room));
120        Arc::clone(entry.value())
121    }
122
123    /// Get a room if it exists.
124    pub fn get(&self, room_id: &str) -> Option<Arc<Room>> {
125        self.rooms.get(room_id).map(|r| Arc::clone(&r))
126    }
127
128    /// Remove a room by id, returning it if present.
129    pub fn remove(&self, room_id: &str) -> Option<Arc<Room>> {
130        self.rooms.remove(room_id).map(|(_, v)| v)
131    }
132
133    /// Number of rooms.
134    pub fn room_count(&self) -> usize {
135        self.rooms.len()
136    }
137
138    /// Remove all empty rooms. Returns number removed.
139    pub fn cleanup(&self) -> usize {
140        let mut to_remove = Vec::new();
141        for entry in self.rooms.iter() {
142            if entry.value().is_empty() {
143                to_remove.push(entry.key().clone());
144            }
145        }
146        let n = to_remove.len();
147        for k in to_remove {
148            self.rooms.remove(&k);
149        }
150        n
151    }
152
153    /// List room ids.
154    pub fn room_ids(&self) -> Vec<String> {
155        self.rooms.iter().map(|e| e.key().clone()).collect()
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn room_join_leave() {
165        let room = Room::new("lobby", 16);
166        assert_eq!(room.participant_count(), 0);
167        room.join("alice");
168        room.join("bob");
169        assert_eq!(room.participant_count(), 2);
170        assert!(room.contains("alice"));
171        assert!(room.leave("alice"));
172        assert!(!room.contains("alice"));
173        assert_eq!(room.participant_count(), 1);
174    }
175
176    #[tokio::test]
177    async fn room_broadcast() {
178        let room = Room::new("chat", 16);
179        let mut rx = room.subscribe();
180        let n = room.broadcast("hello".to_string()).unwrap();
181        assert_eq!(n, 1);
182        assert_eq!(rx.recv().await.unwrap(), "hello");
183    }
184
185    #[tokio::test]
186    async fn room_broadcast_no_receiver() {
187        let room = Room::new("empty", 16);
188        let err = room.broadcast("hi".to_string()).unwrap_err();
189        assert_eq!(err, WsError::BroadcastFull);
190    }
191
192    #[test]
193    fn manager_get_or_create_idempotent() {
194        let m = RoomManager::new();
195        let r1 = m.get_or_create("room1");
196        let r2 = m.get_or_create("room1");
197        assert!(Arc::ptr_eq(&r1, &r2));
198        assert_eq!(m.room_count(), 1);
199    }
200
201    #[test]
202    fn manager_cleanup_removes_empty() {
203        let m = RoomManager::new();
204        let _r = m.get_or_create("a");
205        // Need a receiver or participant to keep alive? is_empty checks both.
206        // No participants and no receivers -> empty, should be cleaned.
207        // But the test creates a room with no receivers, so cleanup should remove.
208        // However `get_or_create` doesn't create a receiver.
209        assert_eq!(m.cleanup(), 1);
210        assert_eq!(m.room_count(), 0);
211
212        let r = m.get_or_create("b");
213        r.join("user1");
214        assert_eq!(m.cleanup(), 0);
215        assert_eq!(m.room_count(), 1);
216    }
217
218    #[test]
219    fn manager_get_and_remove() {
220        let m = RoomManager::new();
221        assert!(m.get("nonexistent").is_none());
222        let r = m.get_or_create("x");
223        assert!(m.get("x").is_some());
224        let removed = m.remove("x").unwrap();
225        assert!(Arc::ptr_eq(&r, &removed));
226        assert!(m.get("x").is_none());
227    }
228
229    #[tokio::test]
230    async fn manager_broadcast_isolation() {
231        let m = RoomManager::new();
232        let r1 = m.get_or_create("r1");
233        let r2 = m.get_or_create("r2");
234        let mut rx1 = r1.subscribe();
235        let mut rx2 = r2.subscribe();
236        r1.broadcast("for r1".to_string()).unwrap();
237        assert_eq!(rx1.recv().await.unwrap(), "for r1");
238        // r2 should not receive r1's message
239        use tokio::time::{timeout, Duration};
240        let res = timeout(Duration::from_millis(10), rx2.recv()).await;
241        assert!(res.is_err());
242    }
243}