Skip to main content

room_daemon/
registry.rs

1//! Persistent user registry for cross-room identity.
2//!
3//! [`UserRegistry`] provides daemon-level user management: registration,
4//! token issuance/validation, room membership tracking, and global status.
5//! Data is persisted as JSON in a configurable data directory.
6//!
7//! This module is standalone — it does not depend on broker internals.
8//! The daemon (`roomd`, #251) wraps it in `Arc<Mutex<_>>` for concurrent access.
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13use std::path::PathBuf;
14use uuid::Uuid;
15
16/// A registered user with cross-room identity.
17#[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/// A daemon-level team grouping.
26///
27/// Teams are cross-room — they exist at the daemon level, not per-room.
28/// Create-on-first-join, delete-on-empty lifecycle.
29#[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/// Persistent storage format — serialized to `users.json`.
37#[derive(Debug, Clone, Serialize, Deserialize, Default)]
38struct RegistryData {
39    users: HashMap<String, User>,
40    /// Maps token UUID string → username.
41    tokens: HashMap<String, String>,
42    /// Daemon-level teams. Absent in legacy files — `Default` fills with empty map.
43    #[serde(default)]
44    teams: HashMap<String, Team>,
45}
46
47/// Daemon-level user registry with persistent storage.
48///
49/// Manages user lifecycle, token auth, room membership, and global status.
50/// All mutations auto-save to `{data_dir}/users.json`.
51#[derive(Debug)]
52pub struct UserRegistry {
53    data: RegistryData,
54    data_dir: PathBuf,
55}
56
57const REGISTRY_FILE: &str = "users.json";
58
59impl UserRegistry {
60    /// Create a new empty registry backed by the given directory.
61    ///
62    /// Does **not** load from disk — use [`UserRegistry::load`] for that.
63    pub fn new(data_dir: PathBuf) -> Self {
64        Self {
65            data: RegistryData::default(),
66            data_dir,
67        }
68    }
69
70    /// Load an existing registry from `{data_dir}/users.json`.
71    ///
72    /// Returns a fresh empty registry if the file does not exist.
73    /// Returns an error only if the file exists but cannot be parsed.
74    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    /// Persist the registry to `{data_dir}/users.json`.
87    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    // ── User CRUD ──────────────────────────────────────────────────
97
98    /// Register a new user. Fails if the username is already taken.
99    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    /// Remove a user and all their tokens.
118    ///
119    /// Returns `true` if the user existed.
120    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    /// Look up a user by username.
130    pub fn get_user(&self, username: &str) -> Option<&User> {
131        self.data.users.get(username)
132    }
133
134    /// List all registered users.
135    pub fn list_users(&self) -> Vec<&User> {
136        self.data.users.values().collect()
137    }
138
139    // ── Token auth ─────────────────────────────────────────────────
140
141    /// Issue a new token for a registered user.
142    ///
143    /// The user must already be registered via [`register_user`].
144    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    /// Validate a token, returning the associated username.
155    pub fn validate_token(&self, token: &str) -> Option<&str> {
156        self.data.tokens.get(token).map(|s| s.as_str())
157    }
158
159    /// Revoke a specific token. Returns `true` if it existed.
160    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    /// Revoke all tokens for a user. Returns the number revoked.
169    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    // ── Room membership ────────────────────────────────────────────
180
181    /// Record that a user has joined a room.
182    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    /// Record that a user has left a room.
193    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    // ── Status ─────────────────────────────────────────────────────
207
208    /// Set or clear a user's global status.
209    ///
210    /// Pass `None` to clear. Status applies across all rooms the user is in.
211    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    /// Return the path to the backing JSON file.
222    pub fn data_path(&self) -> PathBuf {
223        self.data_dir.join(REGISTRY_FILE)
224    }
225
226    // ── Teams ──────────────────────────────────────────────────────
227
228    /// Add a user to a team. Creates the team if it does not exist.
229    ///
230    /// Returns `true` if the user was newly added (not already a member).
231    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    /// Remove a user from a team. Deletes the team if it becomes empty.
253    ///
254    /// Returns `true` if the user was a member and was removed.
255    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    /// Look up a team by name.
271    pub fn get_team(&self, team_name: &str) -> Option<&Team> {
272        self.data.teams.get(team_name)
273    }
274
275    /// List all teams.
276    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    /// Return all team names (sorted). Used for TUI autocomplete.
283    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    /// Expand a mention to team members if it matches a team name.
290    ///
291    /// Returns `None` if the mention is not a team. Returns `Some(members)`
292    /// if it is — the caller can then treat each member as an individual mention.
293    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    /// Return `true` if any token is currently associated with `username`.
301    ///
302    /// Used by daemon auth to detect username collisions without scanning the
303    /// entire token map externally.
304    pub fn has_token_for_user(&self, username: &str) -> bool {
305        self.data.tokens.values().any(|u| u == username)
306    }
307
308    /// Register a user if not already registered; no-op if already present.
309    ///
310    /// Unlike [`register_user`], this is idempotent — calling it for an
311    /// existing user does not return an error. Used by daemon auth so that
312    /// users from a previous session (loaded from `users.json`) can rejoin
313    /// without triggering a registration error.
314    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    /// Return a snapshot of all current token → username mappings.
323    ///
324    /// Used at daemon startup to seed the in-memory `TokenMap` from persisted
325    /// registry data so existing tokens remain valid without a fresh join.
326    pub fn token_snapshot(&self) -> std::collections::HashMap<String, String> {
327        self.data.tokens.clone()
328    }
329
330    /// Insert a pre-existing token UUID for a registered user.
331    ///
332    /// Unlike [`issue_token`], which generates a fresh UUID, this method
333    /// preserves the caller-supplied `token` string. It is intended for
334    /// migration paths that read legacy token files (e.g. `/tmp/room-*-*.token`)
335    /// and want existing clients to remain valid without a forced re-join.
336    ///
337    /// Returns `Ok(())` immediately if the token is already present in the
338    /// registry (idempotent). Returns an error if `username` is not registered.
339    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    // ── User CRUD ──────────────────────────────────────────────────
364
365    #[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    // ── Token auth ─────────────────────────────────────────────────
423
424    #[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    // ── Room membership ────────────────────────────────────────────
485
486    #[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 = &reg.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    // ── Status ─────────────────────────────────────────────────────
524
525    #[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    // ── Persistence ────────────────────────────────────────────────
546
547    #[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            // save is called by each mutation, but explicit save is fine too
558        }
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        // Should not error and should not disturb existing data
596        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    // ── import_token ───────────────────────────────────────────────
620
621    #[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        // Second call must not error and must not change anything.
635        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    // ── Team CRUD ─────────────────────────────────────────────────
670
671    #[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        // Simulate a legacy users.json without the "teams" field.
804        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}