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, PartialEq)]
30pub struct Team {
31 pub name: String,
32 pub members: HashSet<String>,
33 pub created_at: DateTime<Utc>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, Default)]
38struct RegistryData {
39 users: HashMap<String, User>,
40 tokens: HashMap<String, String>,
42 #[serde(default)]
44 teams: HashMap<String, Team>,
45}
46
47#[derive(Debug)]
52pub struct UserRegistry {
53 data: RegistryData,
54 data_dir: PathBuf,
55}
56
57const REGISTRY_FILE: &str = "users.json";
58
59impl UserRegistry {
60 pub fn new(data_dir: PathBuf) -> Self {
64 Self {
65 data: RegistryData::default(),
66 data_dir,
67 }
68 }
69
70 pub fn load(data_dir: PathBuf) -> Result<Self, String> {
75 let path = data_dir.join(REGISTRY_FILE);
76 if !path.exists() {
77 return Ok(Self::new(data_dir));
78 }
79 let contents =
80 std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
81 let data: RegistryData = serde_json::from_str(&contents)
82 .map_err(|e| format!("parse {}: {e}", path.display()))?;
83 Ok(Self { data, data_dir })
84 }
85
86 pub fn save(&self) -> Result<(), String> {
88 std::fs::create_dir_all(&self.data_dir)
89 .map_err(|e| format!("create dir {}: {e}", self.data_dir.display()))?;
90 let path = self.data_dir.join(REGISTRY_FILE);
91 let json = serde_json::to_string_pretty(&self.data)
92 .map_err(|e| format!("serialize registry: {e}"))?;
93 std::fs::write(&path, json).map_err(|e| format!("write {}: {e}", path.display()))
94 }
95
96 pub fn register_user(&mut self, username: &str) -> Result<&User, String> {
100 if username.is_empty() {
101 return Err("username cannot be empty".into());
102 }
103 if self.data.users.contains_key(username) {
104 return Err(format!("username already registered: {username}"));
105 }
106 let user = User {
107 username: username.to_owned(),
108 created_at: Utc::now(),
109 rooms: HashSet::new(),
110 status: None,
111 };
112 self.data.users.insert(username.to_owned(), user);
113 self.save()?;
114 Ok(self.data.users.get(username).unwrap())
115 }
116
117 pub fn remove_user(&mut self, username: &str) -> Result<bool, String> {
121 let existed = self.data.users.remove(username).is_some();
122 if existed {
123 self.data.tokens.retain(|_, u| u != username);
124 self.save()?;
125 }
126 Ok(existed)
127 }
128
129 pub fn get_user(&self, username: &str) -> Option<&User> {
131 self.data.users.get(username)
132 }
133
134 pub fn list_users(&self) -> Vec<&User> {
136 self.data.users.values().collect()
137 }
138
139 pub fn issue_token(&mut self, username: &str) -> Result<String, String> {
145 if !self.data.users.contains_key(username) {
146 return Err(format!("user not registered: {username}"));
147 }
148 let token = Uuid::new_v4().to_string();
149 self.data.tokens.insert(token.clone(), username.to_owned());
150 self.save()?;
151 Ok(token)
152 }
153
154 pub fn validate_token(&self, token: &str) -> Option<&str> {
156 self.data.tokens.get(token).map(|s| s.as_str())
157 }
158
159 pub fn revoke_token(&mut self, token: &str) -> Result<bool, String> {
161 let existed = self.data.tokens.remove(token).is_some();
162 if existed {
163 self.save()?;
164 }
165 Ok(existed)
166 }
167
168 pub fn revoke_user_tokens(&mut self, username: &str) -> Result<usize, String> {
170 let before = self.data.tokens.len();
171 self.data.tokens.retain(|_, u| u != username);
172 let revoked = before - self.data.tokens.len();
173 if revoked > 0 {
174 self.save()?;
175 }
176 Ok(revoked)
177 }
178
179 pub fn join_room(&mut self, username: &str, room_id: &str) -> Result<(), String> {
183 let user = self
184 .data
185 .users
186 .get_mut(username)
187 .ok_or_else(|| format!("user not registered: {username}"))?;
188 user.rooms.insert(room_id.to_owned());
189 self.save()
190 }
191
192 pub fn leave_room(&mut self, username: &str, room_id: &str) -> Result<bool, String> {
194 let user = self
195 .data
196 .users
197 .get_mut(username)
198 .ok_or_else(|| format!("user not registered: {username}"))?;
199 let was_member = user.rooms.remove(room_id);
200 if was_member {
201 self.save()?;
202 }
203 Ok(was_member)
204 }
205
206 pub fn set_status(&mut self, username: &str, status: Option<String>) -> Result<(), String> {
212 let user = self
213 .data
214 .users
215 .get_mut(username)
216 .ok_or_else(|| format!("user not registered: {username}"))?;
217 user.status = status;
218 self.save()
219 }
220
221 pub fn data_path(&self) -> PathBuf {
223 self.data_dir.join(REGISTRY_FILE)
224 }
225
226 pub fn join_team(&mut self, team_name: &str, username: &str) -> Result<bool, String> {
232 if team_name.is_empty() {
233 return Err("team name cannot be empty".into());
234 }
235 if username.is_empty() {
236 return Err("username cannot be empty".into());
237 }
238 let team = self
239 .data
240 .teams
241 .entry(team_name.to_owned())
242 .or_insert_with(|| Team {
243 name: team_name.to_owned(),
244 members: HashSet::new(),
245 created_at: Utc::now(),
246 });
247 let added = team.members.insert(username.to_owned());
248 self.save()?;
249 Ok(added)
250 }
251
252 pub fn leave_team(&mut self, team_name: &str, username: &str) -> Result<bool, String> {
256 let team = match self.data.teams.get_mut(team_name) {
257 Some(t) => t,
258 None => return Err(format!("team not found: {team_name}")),
259 };
260 let removed = team.members.remove(username);
261 if team.members.is_empty() {
262 self.data.teams.remove(team_name);
263 }
264 if removed {
265 self.save()?;
266 }
267 Ok(removed)
268 }
269
270 pub fn get_team(&self, team_name: &str) -> Option<&Team> {
272 self.data.teams.get(team_name)
273 }
274
275 pub fn list_teams(&self) -> Vec<&Team> {
277 let mut teams: Vec<&Team> = self.data.teams.values().collect();
278 teams.sort_by_key(|t| &t.name);
279 teams
280 }
281
282 pub fn team_names(&self) -> Vec<String> {
284 let mut names: Vec<String> = self.data.teams.keys().cloned().collect();
285 names.sort();
286 names
287 }
288
289 pub fn expand_team_mention(&self, mention: &str) -> Option<Vec<String>> {
294 self.data
295 .teams
296 .get(mention)
297 .map(|t| t.members.iter().cloned().collect())
298 }
299
300 pub fn has_token_for_user(&self, username: &str) -> bool {
305 self.data.tokens.values().any(|u| u == username)
306 }
307
308 pub fn register_user_idempotent(&mut self, username: &str) -> Result<(), String> {
315 if self.data.users.contains_key(username) {
316 return Ok(());
317 }
318 self.register_user(username)?;
319 Ok(())
320 }
321
322 pub fn token_snapshot(&self) -> std::collections::HashMap<String, String> {
327 self.data.tokens.clone()
328 }
329
330 pub fn import_token(&mut self, username: &str, token: &str) -> Result<(), String> {
340 if !self.data.users.contains_key(username) {
341 return Err(format!("user not registered: {username}"));
342 }
343 if self.data.tokens.contains_key(token) {
344 return Ok(());
345 }
346 self.data
347 .tokens
348 .insert(token.to_owned(), username.to_owned());
349 self.save()
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 fn tmp_registry() -> (UserRegistry, tempfile::TempDir) {
358 let dir = tempfile::tempdir().unwrap();
359 let reg = UserRegistry::new(dir.path().to_owned());
360 (reg, dir)
361 }
362
363 #[test]
366 fn register_and_get_user() {
367 let (mut reg, _dir) = tmp_registry();
368 let user = reg.register_user("alice").unwrap();
369 assert_eq!(user.username, "alice");
370 assert!(user.rooms.is_empty());
371 assert!(user.status.is_none());
372
373 let fetched = reg.get_user("alice").unwrap();
374 assert_eq!(fetched.username, "alice");
375 }
376
377 #[test]
378 fn register_duplicate_rejected() {
379 let (mut reg, _dir) = tmp_registry();
380 reg.register_user("alice").unwrap();
381 let err = reg.register_user("alice").unwrap_err();
382 assert!(err.contains("already registered"));
383 }
384
385 #[test]
386 fn register_empty_username_rejected() {
387 let (mut reg, _dir) = tmp_registry();
388 let err = reg.register_user("").unwrap_err();
389 assert!(err.contains("cannot be empty"));
390 }
391
392 #[test]
393 fn remove_user_cleans_tokens() {
394 let (mut reg, _dir) = tmp_registry();
395 reg.register_user("alice").unwrap();
396 let token = reg.issue_token("alice").unwrap();
397 assert!(reg.validate_token(&token).is_some());
398
399 reg.remove_user("alice").unwrap();
400 assert!(reg.get_user("alice").is_none());
401 assert!(reg.validate_token(&token).is_none());
402 }
403
404 #[test]
405 fn remove_nonexistent_user_returns_false() {
406 let (mut reg, _dir) = tmp_registry();
407 assert!(!reg.remove_user("ghost").unwrap());
408 }
409
410 #[test]
411 fn list_users_returns_all() {
412 let (mut reg, _dir) = tmp_registry();
413 reg.register_user("alice").unwrap();
414 reg.register_user("bob").unwrap();
415 let users = reg.list_users();
416 assert_eq!(users.len(), 2);
417 let names: HashSet<&str> = users.iter().map(|u| u.username.as_str()).collect();
418 assert!(names.contains("alice"));
419 assert!(names.contains("bob"));
420 }
421
422 #[test]
425 fn issue_and_validate_token() {
426 let (mut reg, _dir) = tmp_registry();
427 reg.register_user("alice").unwrap();
428 let token = reg.issue_token("alice").unwrap();
429 assert_eq!(reg.validate_token(&token), Some("alice"));
430 }
431
432 #[test]
433 fn issue_token_for_unregistered_user_fails() {
434 let (mut reg, _dir) = tmp_registry();
435 let err = reg.issue_token("ghost").unwrap_err();
436 assert!(err.contains("not registered"));
437 }
438
439 #[test]
440 fn validate_unknown_token_returns_none() {
441 let (reg, _dir) = tmp_registry();
442 assert!(reg.validate_token("bad-token").is_none());
443 }
444
445 #[test]
446 fn revoke_token() {
447 let (mut reg, _dir) = tmp_registry();
448 reg.register_user("alice").unwrap();
449 let token = reg.issue_token("alice").unwrap();
450 assert!(reg.revoke_token(&token).unwrap());
451 assert!(reg.validate_token(&token).is_none());
452 }
453
454 #[test]
455 fn revoke_nonexistent_token_returns_false() {
456 let (mut reg, _dir) = tmp_registry();
457 assert!(!reg.revoke_token("nope").unwrap());
458 }
459
460 #[test]
461 fn revoke_user_tokens_removes_all() {
462 let (mut reg, _dir) = tmp_registry();
463 reg.register_user("alice").unwrap();
464 let t1 = reg.issue_token("alice").unwrap();
465 let t2 = reg.issue_token("alice").unwrap();
466 assert_eq!(reg.revoke_user_tokens("alice").unwrap(), 2);
467 assert!(reg.validate_token(&t1).is_none());
468 assert!(reg.validate_token(&t2).is_none());
469 }
470
471 #[test]
472 fn multiple_users_tokens_isolated() {
473 let (mut reg, _dir) = tmp_registry();
474 reg.register_user("alice").unwrap();
475 reg.register_user("bob").unwrap();
476 let ta = reg.issue_token("alice").unwrap();
477 let tb = reg.issue_token("bob").unwrap();
478
479 reg.revoke_user_tokens("alice").unwrap();
480 assert!(reg.validate_token(&ta).is_none());
481 assert_eq!(reg.validate_token(&tb), Some("bob"));
482 }
483
484 #[test]
487 fn join_and_leave_room() {
488 let (mut reg, _dir) = tmp_registry();
489 reg.register_user("alice").unwrap();
490 reg.join_room("alice", "lobby").unwrap();
491 assert!(reg.get_user("alice").unwrap().rooms.contains("lobby"));
492
493 assert!(reg.leave_room("alice", "lobby").unwrap());
494 assert!(!reg.get_user("alice").unwrap().rooms.contains("lobby"));
495 }
496
497 #[test]
498 fn join_multiple_rooms() {
499 let (mut reg, _dir) = tmp_registry();
500 reg.register_user("alice").unwrap();
501 reg.join_room("alice", "room-a").unwrap();
502 reg.join_room("alice", "room-b").unwrap();
503 let rooms = ®.get_user("alice").unwrap().rooms;
504 assert_eq!(rooms.len(), 2);
505 assert!(rooms.contains("room-a"));
506 assert!(rooms.contains("room-b"));
507 }
508
509 #[test]
510 fn leave_room_not_member_returns_false() {
511 let (mut reg, _dir) = tmp_registry();
512 reg.register_user("alice").unwrap();
513 assert!(!reg.leave_room("alice", "nowhere").unwrap());
514 }
515
516 #[test]
517 fn room_ops_on_unregistered_user_fail() {
518 let (mut reg, _dir) = tmp_registry();
519 assert!(reg.join_room("ghost", "lobby").is_err());
520 assert!(reg.leave_room("ghost", "lobby").is_err());
521 }
522
523 #[test]
526 fn set_and_clear_status() {
527 let (mut reg, _dir) = tmp_registry();
528 reg.register_user("alice").unwrap();
529 reg.set_status("alice", Some("coding".into())).unwrap();
530 assert_eq!(
531 reg.get_user("alice").unwrap().status.as_deref(),
532 Some("coding")
533 );
534
535 reg.set_status("alice", None).unwrap();
536 assert!(reg.get_user("alice").unwrap().status.is_none());
537 }
538
539 #[test]
540 fn status_on_unregistered_user_fails() {
541 let (mut reg, _dir) = tmp_registry();
542 assert!(reg.set_status("ghost", Some("hi".into())).is_err());
543 }
544
545 #[test]
548 fn save_and_load_round_trip() {
549 let dir = tempfile::tempdir().unwrap();
550 let token;
551 {
552 let mut reg = UserRegistry::new(dir.path().to_owned());
553 reg.register_user("alice").unwrap();
554 token = reg.issue_token("alice").unwrap();
555 reg.join_room("alice", "lobby").unwrap();
556 reg.set_status("alice", Some("active".into())).unwrap();
557 }
559
560 let loaded = UserRegistry::load(dir.path().to_owned()).unwrap();
561 let user = loaded.get_user("alice").unwrap();
562 assert_eq!(user.username, "alice");
563 assert!(user.rooms.contains("lobby"));
564 assert_eq!(user.status.as_deref(), Some("active"));
565 assert_eq!(loaded.validate_token(&token), Some("alice"));
566 }
567
568 #[test]
569 fn load_missing_file_returns_empty() {
570 let dir = tempfile::tempdir().unwrap();
571 let reg = UserRegistry::load(dir.path().to_owned()).unwrap();
572 assert!(reg.list_users().is_empty());
573 }
574
575 #[test]
576 fn has_token_for_user_true_when_token_exists() {
577 let (mut reg, _dir) = tmp_registry();
578 reg.register_user("alice").unwrap();
579 reg.issue_token("alice").unwrap();
580 assert!(reg.has_token_for_user("alice"));
581 }
582
583 #[test]
584 fn has_token_for_user_false_when_no_token() {
585 let (mut reg, _dir) = tmp_registry();
586 reg.register_user("alice").unwrap();
587 assert!(!reg.has_token_for_user("alice"));
588 }
589
590 #[test]
591 fn register_user_idempotent_noop_for_existing() {
592 let (mut reg, _dir) = tmp_registry();
593 reg.register_user("alice").unwrap();
594 let token = reg.issue_token("alice").unwrap();
595 reg.register_user_idempotent("alice").unwrap();
597 assert_eq!(reg.validate_token(&token), Some("alice"));
598 }
599
600 #[test]
601 fn register_user_idempotent_creates_new_user() {
602 let (mut reg, _dir) = tmp_registry();
603 reg.register_user_idempotent("bob").unwrap();
604 assert!(reg.get_user("bob").is_some());
605 }
606
607 #[test]
608 fn token_snapshot_returns_all_tokens() {
609 let (mut reg, _dir) = tmp_registry();
610 reg.register_user("alice").unwrap();
611 reg.register_user("bob").unwrap();
612 let t1 = reg.issue_token("alice").unwrap();
613 let t2 = reg.issue_token("bob").unwrap();
614 let snap = reg.token_snapshot();
615 assert_eq!(snap.get(&t1).map(String::as_str), Some("alice"));
616 assert_eq!(snap.get(&t2).map(String::as_str), Some("bob"));
617 }
618
619 #[test]
622 fn import_token_preserves_uuid() {
623 let (mut reg, _dir) = tmp_registry();
624 reg.register_user("alice").unwrap();
625 reg.import_token("alice", "legacy-uuid-1234").unwrap();
626 assert_eq!(reg.validate_token("legacy-uuid-1234"), Some("alice"));
627 }
628
629 #[test]
630 fn import_token_noop_if_already_present() {
631 let (mut reg, _dir) = tmp_registry();
632 reg.register_user("alice").unwrap();
633 reg.import_token("alice", "tok-abc").unwrap();
634 reg.import_token("alice", "tok-abc").unwrap();
636 assert_eq!(reg.validate_token("tok-abc"), Some("alice"));
637 }
638
639 #[test]
640 fn import_token_fails_for_unregistered_user() {
641 let (mut reg, _dir) = tmp_registry();
642 let err = reg.import_token("ghost", "tok-xyz").unwrap_err();
643 assert!(err.contains("not registered"));
644 }
645
646 #[test]
647 fn load_corrupt_file_returns_error() {
648 let dir = tempfile::tempdir().unwrap();
649 std::fs::write(dir.path().join(REGISTRY_FILE), "not json{{{").unwrap();
650 let err = UserRegistry::load(dir.path().to_owned()).unwrap_err();
651 assert!(err.contains("parse"));
652 }
653
654 #[test]
655 fn persistence_survives_remove_and_reload() {
656 let dir = tempfile::tempdir().unwrap();
657 {
658 let mut reg = UserRegistry::new(dir.path().to_owned());
659 reg.register_user("alice").unwrap();
660 reg.register_user("bob").unwrap();
661 reg.remove_user("alice").unwrap();
662 }
663
664 let loaded = UserRegistry::load(dir.path().to_owned()).unwrap();
665 assert!(loaded.get_user("alice").is_none());
666 assert!(loaded.get_user("bob").is_some());
667 }
668
669 #[test]
672 fn join_team_creates_and_adds_member() {
673 let (mut reg, _dir) = tmp_registry();
674 assert!(reg.join_team("backend", "alice").unwrap());
675 let team = reg.get_team("backend").unwrap();
676 assert!(team.members.contains("alice"));
677 assert_eq!(team.name, "backend");
678 }
679
680 #[test]
681 fn join_team_idempotent_returns_false() {
682 let (mut reg, _dir) = tmp_registry();
683 assert!(reg.join_team("backend", "alice").unwrap());
684 assert!(!reg.join_team("backend", "alice").unwrap());
685 }
686
687 #[test]
688 fn join_team_multiple_members() {
689 let (mut reg, _dir) = tmp_registry();
690 reg.join_team("backend", "alice").unwrap();
691 reg.join_team("backend", "bob").unwrap();
692 let team = reg.get_team("backend").unwrap();
693 assert_eq!(team.members.len(), 2);
694 assert!(team.members.contains("alice"));
695 assert!(team.members.contains("bob"));
696 }
697
698 #[test]
699 fn join_team_empty_name_rejected() {
700 let (mut reg, _dir) = tmp_registry();
701 let err = reg.join_team("", "alice").unwrap_err();
702 assert!(err.contains("team name cannot be empty"));
703 }
704
705 #[test]
706 fn join_team_empty_username_rejected() {
707 let (mut reg, _dir) = tmp_registry();
708 let err = reg.join_team("backend", "").unwrap_err();
709 assert!(err.contains("username cannot be empty"));
710 }
711
712 #[test]
713 fn leave_team_removes_member() {
714 let (mut reg, _dir) = tmp_registry();
715 reg.join_team("backend", "alice").unwrap();
716 reg.join_team("backend", "bob").unwrap();
717 assert!(reg.leave_team("backend", "alice").unwrap());
718 let team = reg.get_team("backend").unwrap();
719 assert!(!team.members.contains("alice"));
720 assert!(team.members.contains("bob"));
721 }
722
723 #[test]
724 fn leave_team_deletes_on_empty() {
725 let (mut reg, _dir) = tmp_registry();
726 reg.join_team("backend", "alice").unwrap();
727 reg.leave_team("backend", "alice").unwrap();
728 assert!(reg.get_team("backend").is_none());
729 }
730
731 #[test]
732 fn leave_team_nonexistent_returns_error() {
733 let (mut reg, _dir) = tmp_registry();
734 let err = reg.leave_team("nope", "alice").unwrap_err();
735 assert!(err.contains("team not found"));
736 }
737
738 #[test]
739 fn leave_team_non_member_returns_false() {
740 let (mut reg, _dir) = tmp_registry();
741 reg.join_team("backend", "alice").unwrap();
742 assert!(!reg.leave_team("backend", "bob").unwrap());
743 }
744
745 #[test]
746 fn list_teams_sorted_by_name() {
747 let (mut reg, _dir) = tmp_registry();
748 reg.join_team("zeta", "alice").unwrap();
749 reg.join_team("alpha", "bob").unwrap();
750 let teams = reg.list_teams();
751 assert_eq!(teams.len(), 2);
752 assert_eq!(teams[0].name, "alpha");
753 assert_eq!(teams[1].name, "zeta");
754 }
755
756 #[test]
757 fn team_names_returns_sorted_vec() {
758 let (mut reg, _dir) = tmp_registry();
759 reg.join_team("zeta", "alice").unwrap();
760 reg.join_team("alpha", "bob").unwrap();
761 let names = reg.team_names();
762 assert_eq!(names, vec!["alpha", "zeta"]);
763 }
764
765 #[test]
766 fn expand_team_mention_returns_members() {
767 let (mut reg, _dir) = tmp_registry();
768 reg.join_team("backend", "alice").unwrap();
769 reg.join_team("backend", "bob").unwrap();
770 let members = reg.expand_team_mention("backend").unwrap();
771 assert_eq!(members.len(), 2);
772 assert!(members.contains(&"alice".to_owned()));
773 assert!(members.contains(&"bob".to_owned()));
774 }
775
776 #[test]
777 fn expand_team_mention_nonexistent_returns_none() {
778 let (reg, _dir) = tmp_registry();
779 assert!(reg.expand_team_mention("nonexistent").is_none());
780 }
781
782 #[test]
783 fn teams_persist_across_save_and_load() {
784 let dir = tempfile::tempdir().unwrap();
785 {
786 let mut reg = UserRegistry::new(dir.path().to_owned());
787 reg.join_team("backend", "alice").unwrap();
788 reg.join_team("backend", "bob").unwrap();
789 reg.join_team("frontend", "carol").unwrap();
790 }
791
792 let loaded = UserRegistry::load(dir.path().to_owned()).unwrap();
793 let backend = loaded.get_team("backend").unwrap();
794 assert_eq!(backend.members.len(), 2);
795 assert!(backend.members.contains("alice"));
796 let frontend = loaded.get_team("frontend").unwrap();
797 assert!(frontend.members.contains("carol"));
798 }
799
800 #[test]
801 fn legacy_registry_without_teams_loads_cleanly() {
802 let dir = tempfile::tempdir().unwrap();
803 let legacy_json = r#"{"users":{},"tokens":{}}"#;
805 std::fs::write(dir.path().join(REGISTRY_FILE), legacy_json).unwrap();
806 let reg = UserRegistry::load(dir.path().to_owned()).unwrap();
807 assert!(reg.list_teams().is_empty());
808 }
809}