1use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13use std::path::PathBuf;
14use uuid::Uuid;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct User {
19 pub username: String,
20 pub created_at: DateTime<Utc>,
21 pub rooms: HashSet<String>,
22 pub status: Option<String>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, Default)]
27struct RegistryData {
28 users: HashMap<String, User>,
29 tokens: HashMap<String, String>,
31}
32
33#[derive(Debug)]
38pub struct UserRegistry {
39 data: RegistryData,
40 data_dir: PathBuf,
41}
42
43const REGISTRY_FILE: &str = "users.json";
44
45impl UserRegistry {
46 pub fn new(data_dir: PathBuf) -> Self {
50 Self {
51 data: RegistryData::default(),
52 data_dir,
53 }
54 }
55
56 pub fn load(data_dir: PathBuf) -> Result<Self, String> {
61 let path = data_dir.join(REGISTRY_FILE);
62 if !path.exists() {
63 return Ok(Self::new(data_dir));
64 }
65 let contents =
66 std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
67 let data: RegistryData = serde_json::from_str(&contents)
68 .map_err(|e| format!("parse {}: {e}", path.display()))?;
69 Ok(Self { data, data_dir })
70 }
71
72 pub fn save(&self) -> Result<(), String> {
74 std::fs::create_dir_all(&self.data_dir)
75 .map_err(|e| format!("create dir {}: {e}", self.data_dir.display()))?;
76 let path = self.data_dir.join(REGISTRY_FILE);
77 let json = serde_json::to_string_pretty(&self.data)
78 .map_err(|e| format!("serialize registry: {e}"))?;
79 std::fs::write(&path, json).map_err(|e| format!("write {}: {e}", path.display()))
80 }
81
82 pub fn register_user(&mut self, username: &str) -> Result<&User, String> {
86 if username.is_empty() {
87 return Err("username cannot be empty".into());
88 }
89 if self.data.users.contains_key(username) {
90 return Err(format!("username already registered: {username}"));
91 }
92 let user = User {
93 username: username.to_owned(),
94 created_at: Utc::now(),
95 rooms: HashSet::new(),
96 status: None,
97 };
98 self.data.users.insert(username.to_owned(), user);
99 self.save()?;
100 Ok(self.data.users.get(username).unwrap())
101 }
102
103 pub fn remove_user(&mut self, username: &str) -> Result<bool, String> {
107 let existed = self.data.users.remove(username).is_some();
108 if existed {
109 self.data.tokens.retain(|_, u| u != username);
110 self.save()?;
111 }
112 Ok(existed)
113 }
114
115 pub fn get_user(&self, username: &str) -> Option<&User> {
117 self.data.users.get(username)
118 }
119
120 pub fn list_users(&self) -> Vec<&User> {
122 self.data.users.values().collect()
123 }
124
125 pub fn issue_token(&mut self, username: &str) -> Result<String, String> {
131 if !self.data.users.contains_key(username) {
132 return Err(format!("user not registered: {username}"));
133 }
134 let token = Uuid::new_v4().to_string();
135 self.data.tokens.insert(token.clone(), username.to_owned());
136 self.save()?;
137 Ok(token)
138 }
139
140 pub fn validate_token(&self, token: &str) -> Option<&str> {
142 self.data.tokens.get(token).map(|s| s.as_str())
143 }
144
145 pub fn revoke_token(&mut self, token: &str) -> Result<bool, String> {
147 let existed = self.data.tokens.remove(token).is_some();
148 if existed {
149 self.save()?;
150 }
151 Ok(existed)
152 }
153
154 pub fn revoke_user_tokens(&mut self, username: &str) -> Result<usize, String> {
156 let before = self.data.tokens.len();
157 self.data.tokens.retain(|_, u| u != username);
158 let revoked = before - self.data.tokens.len();
159 if revoked > 0 {
160 self.save()?;
161 }
162 Ok(revoked)
163 }
164
165 pub fn join_room(&mut self, username: &str, room_id: &str) -> Result<(), String> {
169 let user = self
170 .data
171 .users
172 .get_mut(username)
173 .ok_or_else(|| format!("user not registered: {username}"))?;
174 user.rooms.insert(room_id.to_owned());
175 self.save()
176 }
177
178 pub fn leave_room(&mut self, username: &str, room_id: &str) -> Result<bool, String> {
180 let user = self
181 .data
182 .users
183 .get_mut(username)
184 .ok_or_else(|| format!("user not registered: {username}"))?;
185 let was_member = user.rooms.remove(room_id);
186 if was_member {
187 self.save()?;
188 }
189 Ok(was_member)
190 }
191
192 pub fn set_status(&mut self, username: &str, status: Option<String>) -> Result<(), String> {
198 let user = self
199 .data
200 .users
201 .get_mut(username)
202 .ok_or_else(|| format!("user not registered: {username}"))?;
203 user.status = status;
204 self.save()
205 }
206
207 pub fn data_path(&self) -> PathBuf {
209 self.data_dir.join(REGISTRY_FILE)
210 }
211
212 pub fn has_token_for_user(&self, username: &str) -> bool {
217 self.data.tokens.values().any(|u| u == username)
218 }
219
220 pub fn register_user_idempotent(&mut self, username: &str) -> Result<(), String> {
227 if self.data.users.contains_key(username) {
228 return Ok(());
229 }
230 self.register_user(username)?;
231 Ok(())
232 }
233
234 pub fn token_snapshot(&self) -> std::collections::HashMap<String, String> {
239 self.data.tokens.clone()
240 }
241
242 pub fn import_token(&mut self, username: &str, token: &str) -> Result<(), String> {
252 if !self.data.users.contains_key(username) {
253 return Err(format!("user not registered: {username}"));
254 }
255 if self.data.tokens.contains_key(token) {
256 return Ok(());
257 }
258 self.data
259 .tokens
260 .insert(token.to_owned(), username.to_owned());
261 self.save()
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 fn tmp_registry() -> (UserRegistry, tempfile::TempDir) {
270 let dir = tempfile::tempdir().unwrap();
271 let reg = UserRegistry::new(dir.path().to_owned());
272 (reg, dir)
273 }
274
275 #[test]
278 fn register_and_get_user() {
279 let (mut reg, _dir) = tmp_registry();
280 let user = reg.register_user("alice").unwrap();
281 assert_eq!(user.username, "alice");
282 assert!(user.rooms.is_empty());
283 assert!(user.status.is_none());
284
285 let fetched = reg.get_user("alice").unwrap();
286 assert_eq!(fetched.username, "alice");
287 }
288
289 #[test]
290 fn register_duplicate_rejected() {
291 let (mut reg, _dir) = tmp_registry();
292 reg.register_user("alice").unwrap();
293 let err = reg.register_user("alice").unwrap_err();
294 assert!(err.contains("already registered"));
295 }
296
297 #[test]
298 fn register_empty_username_rejected() {
299 let (mut reg, _dir) = tmp_registry();
300 let err = reg.register_user("").unwrap_err();
301 assert!(err.contains("cannot be empty"));
302 }
303
304 #[test]
305 fn remove_user_cleans_tokens() {
306 let (mut reg, _dir) = tmp_registry();
307 reg.register_user("alice").unwrap();
308 let token = reg.issue_token("alice").unwrap();
309 assert!(reg.validate_token(&token).is_some());
310
311 reg.remove_user("alice").unwrap();
312 assert!(reg.get_user("alice").is_none());
313 assert!(reg.validate_token(&token).is_none());
314 }
315
316 #[test]
317 fn remove_nonexistent_user_returns_false() {
318 let (mut reg, _dir) = tmp_registry();
319 assert!(!reg.remove_user("ghost").unwrap());
320 }
321
322 #[test]
323 fn list_users_returns_all() {
324 let (mut reg, _dir) = tmp_registry();
325 reg.register_user("alice").unwrap();
326 reg.register_user("bob").unwrap();
327 let users = reg.list_users();
328 assert_eq!(users.len(), 2);
329 let names: HashSet<&str> = users.iter().map(|u| u.username.as_str()).collect();
330 assert!(names.contains("alice"));
331 assert!(names.contains("bob"));
332 }
333
334 #[test]
337 fn issue_and_validate_token() {
338 let (mut reg, _dir) = tmp_registry();
339 reg.register_user("alice").unwrap();
340 let token = reg.issue_token("alice").unwrap();
341 assert_eq!(reg.validate_token(&token), Some("alice"));
342 }
343
344 #[test]
345 fn issue_token_for_unregistered_user_fails() {
346 let (mut reg, _dir) = tmp_registry();
347 let err = reg.issue_token("ghost").unwrap_err();
348 assert!(err.contains("not registered"));
349 }
350
351 #[test]
352 fn validate_unknown_token_returns_none() {
353 let (reg, _dir) = tmp_registry();
354 assert!(reg.validate_token("bad-token").is_none());
355 }
356
357 #[test]
358 fn revoke_token() {
359 let (mut reg, _dir) = tmp_registry();
360 reg.register_user("alice").unwrap();
361 let token = reg.issue_token("alice").unwrap();
362 assert!(reg.revoke_token(&token).unwrap());
363 assert!(reg.validate_token(&token).is_none());
364 }
365
366 #[test]
367 fn revoke_nonexistent_token_returns_false() {
368 let (mut reg, _dir) = tmp_registry();
369 assert!(!reg.revoke_token("nope").unwrap());
370 }
371
372 #[test]
373 fn revoke_user_tokens_removes_all() {
374 let (mut reg, _dir) = tmp_registry();
375 reg.register_user("alice").unwrap();
376 let t1 = reg.issue_token("alice").unwrap();
377 let t2 = reg.issue_token("alice").unwrap();
378 assert_eq!(reg.revoke_user_tokens("alice").unwrap(), 2);
379 assert!(reg.validate_token(&t1).is_none());
380 assert!(reg.validate_token(&t2).is_none());
381 }
382
383 #[test]
384 fn multiple_users_tokens_isolated() {
385 let (mut reg, _dir) = tmp_registry();
386 reg.register_user("alice").unwrap();
387 reg.register_user("bob").unwrap();
388 let ta = reg.issue_token("alice").unwrap();
389 let tb = reg.issue_token("bob").unwrap();
390
391 reg.revoke_user_tokens("alice").unwrap();
392 assert!(reg.validate_token(&ta).is_none());
393 assert_eq!(reg.validate_token(&tb), Some("bob"));
394 }
395
396 #[test]
399 fn join_and_leave_room() {
400 let (mut reg, _dir) = tmp_registry();
401 reg.register_user("alice").unwrap();
402 reg.join_room("alice", "lobby").unwrap();
403 assert!(reg.get_user("alice").unwrap().rooms.contains("lobby"));
404
405 assert!(reg.leave_room("alice", "lobby").unwrap());
406 assert!(!reg.get_user("alice").unwrap().rooms.contains("lobby"));
407 }
408
409 #[test]
410 fn join_multiple_rooms() {
411 let (mut reg, _dir) = tmp_registry();
412 reg.register_user("alice").unwrap();
413 reg.join_room("alice", "room-a").unwrap();
414 reg.join_room("alice", "room-b").unwrap();
415 let rooms = ®.get_user("alice").unwrap().rooms;
416 assert_eq!(rooms.len(), 2);
417 assert!(rooms.contains("room-a"));
418 assert!(rooms.contains("room-b"));
419 }
420
421 #[test]
422 fn leave_room_not_member_returns_false() {
423 let (mut reg, _dir) = tmp_registry();
424 reg.register_user("alice").unwrap();
425 assert!(!reg.leave_room("alice", "nowhere").unwrap());
426 }
427
428 #[test]
429 fn room_ops_on_unregistered_user_fail() {
430 let (mut reg, _dir) = tmp_registry();
431 assert!(reg.join_room("ghost", "lobby").is_err());
432 assert!(reg.leave_room("ghost", "lobby").is_err());
433 }
434
435 #[test]
438 fn set_and_clear_status() {
439 let (mut reg, _dir) = tmp_registry();
440 reg.register_user("alice").unwrap();
441 reg.set_status("alice", Some("coding".into())).unwrap();
442 assert_eq!(
443 reg.get_user("alice").unwrap().status.as_deref(),
444 Some("coding")
445 );
446
447 reg.set_status("alice", None).unwrap();
448 assert!(reg.get_user("alice").unwrap().status.is_none());
449 }
450
451 #[test]
452 fn status_on_unregistered_user_fails() {
453 let (mut reg, _dir) = tmp_registry();
454 assert!(reg.set_status("ghost", Some("hi".into())).is_err());
455 }
456
457 #[test]
460 fn save_and_load_round_trip() {
461 let dir = tempfile::tempdir().unwrap();
462 let token;
463 {
464 let mut reg = UserRegistry::new(dir.path().to_owned());
465 reg.register_user("alice").unwrap();
466 token = reg.issue_token("alice").unwrap();
467 reg.join_room("alice", "lobby").unwrap();
468 reg.set_status("alice", Some("active".into())).unwrap();
469 }
471
472 let loaded = UserRegistry::load(dir.path().to_owned()).unwrap();
473 let user = loaded.get_user("alice").unwrap();
474 assert_eq!(user.username, "alice");
475 assert!(user.rooms.contains("lobby"));
476 assert_eq!(user.status.as_deref(), Some("active"));
477 assert_eq!(loaded.validate_token(&token), Some("alice"));
478 }
479
480 #[test]
481 fn load_missing_file_returns_empty() {
482 let dir = tempfile::tempdir().unwrap();
483 let reg = UserRegistry::load(dir.path().to_owned()).unwrap();
484 assert!(reg.list_users().is_empty());
485 }
486
487 #[test]
488 fn has_token_for_user_true_when_token_exists() {
489 let (mut reg, _dir) = tmp_registry();
490 reg.register_user("alice").unwrap();
491 reg.issue_token("alice").unwrap();
492 assert!(reg.has_token_for_user("alice"));
493 }
494
495 #[test]
496 fn has_token_for_user_false_when_no_token() {
497 let (mut reg, _dir) = tmp_registry();
498 reg.register_user("alice").unwrap();
499 assert!(!reg.has_token_for_user("alice"));
500 }
501
502 #[test]
503 fn register_user_idempotent_noop_for_existing() {
504 let (mut reg, _dir) = tmp_registry();
505 reg.register_user("alice").unwrap();
506 let token = reg.issue_token("alice").unwrap();
507 reg.register_user_idempotent("alice").unwrap();
509 assert_eq!(reg.validate_token(&token), Some("alice"));
510 }
511
512 #[test]
513 fn register_user_idempotent_creates_new_user() {
514 let (mut reg, _dir) = tmp_registry();
515 reg.register_user_idempotent("bob").unwrap();
516 assert!(reg.get_user("bob").is_some());
517 }
518
519 #[test]
520 fn token_snapshot_returns_all_tokens() {
521 let (mut reg, _dir) = tmp_registry();
522 reg.register_user("alice").unwrap();
523 reg.register_user("bob").unwrap();
524 let t1 = reg.issue_token("alice").unwrap();
525 let t2 = reg.issue_token("bob").unwrap();
526 let snap = reg.token_snapshot();
527 assert_eq!(snap.get(&t1).map(String::as_str), Some("alice"));
528 assert_eq!(snap.get(&t2).map(String::as_str), Some("bob"));
529 }
530
531 #[test]
534 fn import_token_preserves_uuid() {
535 let (mut reg, _dir) = tmp_registry();
536 reg.register_user("alice").unwrap();
537 reg.import_token("alice", "legacy-uuid-1234").unwrap();
538 assert_eq!(reg.validate_token("legacy-uuid-1234"), Some("alice"));
539 }
540
541 #[test]
542 fn import_token_noop_if_already_present() {
543 let (mut reg, _dir) = tmp_registry();
544 reg.register_user("alice").unwrap();
545 reg.import_token("alice", "tok-abc").unwrap();
546 reg.import_token("alice", "tok-abc").unwrap();
548 assert_eq!(reg.validate_token("tok-abc"), Some("alice"));
549 }
550
551 #[test]
552 fn import_token_fails_for_unregistered_user() {
553 let (mut reg, _dir) = tmp_registry();
554 let err = reg.import_token("ghost", "tok-xyz").unwrap_err();
555 assert!(err.contains("not registered"));
556 }
557
558 #[test]
559 fn load_corrupt_file_returns_error() {
560 let dir = tempfile::tempdir().unwrap();
561 std::fs::write(dir.path().join(REGISTRY_FILE), "not json{{{").unwrap();
562 let err = UserRegistry::load(dir.path().to_owned()).unwrap_err();
563 assert!(err.contains("parse"));
564 }
565
566 #[test]
567 fn persistence_survives_remove_and_reload() {
568 let dir = tempfile::tempdir().unwrap();
569 {
570 let mut reg = UserRegistry::new(dir.path().to_owned());
571 reg.register_user("alice").unwrap();
572 reg.register_user("bob").unwrap();
573 reg.remove_user("alice").unwrap();
574 }
575
576 let loaded = UserRegistry::load(dir.path().to_owned()).unwrap();
577 assert!(loaded.get_user("alice").is_none());
578 assert!(loaded.get_user("bob").is_some());
579 }
580}