Skip to main content

room_protocol/
lib.rs

1use std::collections::HashSet;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7/// Visibility level for a room, controlling who can discover and join it.
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum RoomVisibility {
11    /// Anyone can discover and join.
12    Public,
13    /// Discoverable in listings but requires invite to join.
14    Private,
15    /// Not discoverable; join requires knowing room ID + invite.
16    Unlisted,
17    /// Private 2-person room, auto-created by `/dm` command.
18    Dm,
19}
20
21/// Configuration for a room's access controls and metadata.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct RoomConfig {
24    pub visibility: RoomVisibility,
25    /// Maximum number of members. `None` = unlimited.
26    pub max_members: Option<usize>,
27    /// Usernames allowed to join (for private/unlisted/dm rooms).
28    pub invite_list: HashSet<String>,
29    /// Username of the room creator.
30    pub created_by: String,
31    /// ISO 8601 creation timestamp.
32    pub created_at: String,
33}
34
35impl RoomConfig {
36    /// Create a default public room config.
37    pub fn public(created_by: &str) -> Self {
38        Self {
39            visibility: RoomVisibility::Public,
40            max_members: None,
41            invite_list: HashSet::new(),
42            created_by: created_by.to_owned(),
43            created_at: Utc::now().to_rfc3339(),
44        }
45    }
46
47    /// Create a DM room config for two users.
48    pub fn dm(user_a: &str, user_b: &str) -> Self {
49        let mut invite_list = HashSet::new();
50        invite_list.insert(user_a.to_owned());
51        invite_list.insert(user_b.to_owned());
52        Self {
53            visibility: RoomVisibility::Dm,
54            max_members: Some(2),
55            invite_list,
56            created_by: user_a.to_owned(),
57            created_at: Utc::now().to_rfc3339(),
58        }
59    }
60}
61
62/// Compute the deterministic room ID for a DM between two users.
63///
64/// Sorts usernames alphabetically so `/dm alice` from bob and `/dm bob` from
65/// alice both resolve to the same room.
66pub fn dm_room_id(user_a: &str, user_b: &str) -> String {
67    let (first, second) = if user_a < user_b {
68        (user_a, user_b)
69    } else {
70        (user_b, user_a)
71    };
72    format!("dm-{first}-{second}")
73}
74
75/// Entry returned by room listing (discovery).
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct RoomListEntry {
78    pub room_id: String,
79    pub visibility: RoomVisibility,
80    pub member_count: usize,
81    pub created_by: String,
82}
83
84/// Wire format for all messages stored in the chat file and sent over the socket.
85///
86/// Uses `#[serde(tag = "type")]` internally-tagged enum **without** `#[serde(flatten)]`
87/// to avoid the serde flatten + internally-tagged footgun that breaks deserialization.
88/// Every variant carries its own id/room/user/ts fields.
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90#[serde(tag = "type", rename_all = "snake_case")]
91pub enum Message {
92    Join {
93        id: String,
94        room: String,
95        user: String,
96        ts: DateTime<Utc>,
97        #[serde(default, skip_serializing_if = "Option::is_none")]
98        seq: Option<u64>,
99    },
100    Leave {
101        id: String,
102        room: String,
103        user: String,
104        ts: DateTime<Utc>,
105        #[serde(default, skip_serializing_if = "Option::is_none")]
106        seq: Option<u64>,
107    },
108    Message {
109        id: String,
110        room: String,
111        user: String,
112        ts: DateTime<Utc>,
113        #[serde(default, skip_serializing_if = "Option::is_none")]
114        seq: Option<u64>,
115        content: String,
116    },
117    Reply {
118        id: String,
119        room: String,
120        user: String,
121        ts: DateTime<Utc>,
122        #[serde(default, skip_serializing_if = "Option::is_none")]
123        seq: Option<u64>,
124        reply_to: String,
125        content: String,
126    },
127    Command {
128        id: String,
129        room: String,
130        user: String,
131        ts: DateTime<Utc>,
132        #[serde(default, skip_serializing_if = "Option::is_none")]
133        seq: Option<u64>,
134        cmd: String,
135        params: Vec<String>,
136    },
137    System {
138        id: String,
139        room: String,
140        user: String,
141        ts: DateTime<Utc>,
142        #[serde(default, skip_serializing_if = "Option::is_none")]
143        seq: Option<u64>,
144        content: String,
145    },
146    /// A private direct message. Delivered only to sender, recipient, and the
147    /// broker host. Always written to the chat history file.
148    #[serde(rename = "dm")]
149    DirectMessage {
150        id: String,
151        room: String,
152        /// Sender username (set by the broker).
153        user: String,
154        ts: DateTime<Utc>,
155        #[serde(default, skip_serializing_if = "Option::is_none")]
156        seq: Option<u64>,
157        /// Recipient username.
158        to: String,
159        content: String,
160    },
161}
162
163impl Message {
164    pub fn id(&self) -> &str {
165        match self {
166            Self::Join { id, .. }
167            | Self::Leave { id, .. }
168            | Self::Message { id, .. }
169            | Self::Reply { id, .. }
170            | Self::Command { id, .. }
171            | Self::System { id, .. }
172            | Self::DirectMessage { id, .. } => id,
173        }
174    }
175
176    pub fn room(&self) -> &str {
177        match self {
178            Self::Join { room, .. }
179            | Self::Leave { room, .. }
180            | Self::Message { room, .. }
181            | Self::Reply { room, .. }
182            | Self::Command { room, .. }
183            | Self::System { room, .. }
184            | Self::DirectMessage { room, .. } => room,
185        }
186    }
187
188    pub fn user(&self) -> &str {
189        match self {
190            Self::Join { user, .. }
191            | Self::Leave { user, .. }
192            | Self::Message { user, .. }
193            | Self::Reply { user, .. }
194            | Self::Command { user, .. }
195            | Self::System { user, .. }
196            | Self::DirectMessage { user, .. } => user,
197        }
198    }
199
200    pub fn ts(&self) -> &DateTime<Utc> {
201        match self {
202            Self::Join { ts, .. }
203            | Self::Leave { ts, .. }
204            | Self::Message { ts, .. }
205            | Self::Reply { ts, .. }
206            | Self::Command { ts, .. }
207            | Self::System { ts, .. }
208            | Self::DirectMessage { ts, .. } => ts,
209        }
210    }
211
212    /// Returns the sequence number assigned by the broker, or `None` for
213    /// messages loaded from history files that predate this feature.
214    pub fn seq(&self) -> Option<u64> {
215        match self {
216            Self::Join { seq, .. }
217            | Self::Leave { seq, .. }
218            | Self::Message { seq, .. }
219            | Self::Reply { seq, .. }
220            | Self::Command { seq, .. }
221            | Self::System { seq, .. }
222            | Self::DirectMessage { seq, .. } => *seq,
223        }
224    }
225
226    /// Returns the text content of this message, or `None` for variants without content
227    /// (Join, Leave, Command).
228    pub fn content(&self) -> Option<&str> {
229        match self {
230            Self::Message { content, .. }
231            | Self::Reply { content, .. }
232            | Self::System { content, .. }
233            | Self::DirectMessage { content, .. } => Some(content),
234            Self::Join { .. } | Self::Leave { .. } | Self::Command { .. } => None,
235        }
236    }
237
238    /// Extract @mentions from this message's content.
239    ///
240    /// Returns an empty vec for variants without content (Join, Leave, Command)
241    /// or content with no @mentions.
242    pub fn mentions(&self) -> Vec<String> {
243        match self.content() {
244            Some(content) => parse_mentions(content),
245            None => Vec::new(),
246        }
247    }
248
249    /// Assign a broker-issued sequence number to this message.
250    pub fn set_seq(&mut self, seq: u64) {
251        let n = Some(seq);
252        match self {
253            Self::Join { seq, .. } => *seq = n,
254            Self::Leave { seq, .. } => *seq = n,
255            Self::Message { seq, .. } => *seq = n,
256            Self::Reply { seq, .. } => *seq = n,
257            Self::Command { seq, .. } => *seq = n,
258            Self::System { seq, .. } => *seq = n,
259            Self::DirectMessage { seq, .. } => *seq = n,
260        }
261    }
262}
263
264// ── Constructors ─────────────────────────────────────────────────────────────
265
266fn new_id() -> String {
267    Uuid::new_v4().to_string()
268}
269
270pub fn make_join(room: &str, user: &str) -> Message {
271    Message::Join {
272        id: new_id(),
273        room: room.to_owned(),
274        user: user.to_owned(),
275        ts: Utc::now(),
276        seq: None,
277    }
278}
279
280pub fn make_leave(room: &str, user: &str) -> Message {
281    Message::Leave {
282        id: new_id(),
283        room: room.to_owned(),
284        user: user.to_owned(),
285        ts: Utc::now(),
286        seq: None,
287    }
288}
289
290pub fn make_message(room: &str, user: &str, content: impl Into<String>) -> Message {
291    Message::Message {
292        id: new_id(),
293        room: room.to_owned(),
294        user: user.to_owned(),
295        ts: Utc::now(),
296        content: content.into(),
297        seq: None,
298    }
299}
300
301pub fn make_reply(
302    room: &str,
303    user: &str,
304    reply_to: impl Into<String>,
305    content: impl Into<String>,
306) -> Message {
307    Message::Reply {
308        id: new_id(),
309        room: room.to_owned(),
310        user: user.to_owned(),
311        ts: Utc::now(),
312        reply_to: reply_to.into(),
313        content: content.into(),
314        seq: None,
315    }
316}
317
318pub fn make_command(
319    room: &str,
320    user: &str,
321    cmd: impl Into<String>,
322    params: Vec<String>,
323) -> Message {
324    Message::Command {
325        id: new_id(),
326        room: room.to_owned(),
327        user: user.to_owned(),
328        ts: Utc::now(),
329        cmd: cmd.into(),
330        params,
331        seq: None,
332    }
333}
334
335pub fn make_system(room: &str, user: &str, content: impl Into<String>) -> Message {
336    Message::System {
337        id: new_id(),
338        room: room.to_owned(),
339        user: user.to_owned(),
340        ts: Utc::now(),
341        content: content.into(),
342        seq: None,
343    }
344}
345
346pub fn make_dm(room: &str, user: &str, to: &str, content: impl Into<String>) -> Message {
347    Message::DirectMessage {
348        id: new_id(),
349        room: room.to_owned(),
350        user: user.to_owned(),
351        ts: Utc::now(),
352        to: to.to_owned(),
353        content: content.into(),
354        seq: None,
355    }
356}
357
358/// Extract @mentions from message content.
359///
360/// Matches `@username` patterns where usernames can contain alphanumerics, hyphens,
361/// and underscores. Stops at whitespace, punctuation (except `-` and `_`), or end of
362/// string. Skips email-like patterns (`user@domain`) by requiring the `@` to be at
363/// the start of the string or preceded by whitespace.
364///
365/// Returns a deduplicated list of mentioned usernames (without the `@` prefix),
366/// preserving first-occurrence order.
367pub fn parse_mentions(content: &str) -> Vec<String> {
368    let mut mentions = Vec::new();
369    let mut seen = HashSet::new();
370
371    for (i, _) in content.match_indices('@') {
372        // Skip if preceded by a non-whitespace char (email-like pattern)
373        if i > 0 {
374            let prev = content.as_bytes()[i - 1];
375            if !prev.is_ascii_whitespace() {
376                continue;
377            }
378        }
379
380        // Extract username chars after @
381        let rest = &content[i + 1..];
382        let end = rest
383            .find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
384            .unwrap_or(rest.len());
385        let username = &rest[..end];
386
387        if !username.is_empty() && seen.insert(username.to_owned()) {
388            mentions.push(username.to_owned());
389        }
390    }
391
392    mentions
393}
394
395/// Parse a raw line from a client socket.
396/// JSON envelope → Message with broker-assigned id/room/ts.
397/// Plain text → Message::Message with broker-assigned metadata.
398pub fn parse_client_line(raw: &str, room: &str, user: &str) -> Result<Message, serde_json::Error> {
399    #[derive(Deserialize)]
400    #[serde(tag = "type", rename_all = "snake_case")]
401    enum Envelope {
402        Message {
403            content: String,
404        },
405        Reply {
406            reply_to: String,
407            content: String,
408        },
409        Command {
410            cmd: String,
411            params: Vec<String>,
412        },
413        #[serde(rename = "dm")]
414        Dm {
415            to: String,
416            content: String,
417        },
418    }
419
420    if raw.starts_with('{') {
421        let env: Envelope = serde_json::from_str(raw)?;
422        let msg = match env {
423            Envelope::Message { content } => make_message(room, user, content),
424            Envelope::Reply { reply_to, content } => make_reply(room, user, reply_to, content),
425            Envelope::Command { cmd, params } => make_command(room, user, cmd, params),
426            Envelope::Dm { to, content } => make_dm(room, user, &to, content),
427        };
428        Ok(msg)
429    } else {
430        Ok(make_message(room, user, raw))
431    }
432}
433
434// ── Tests ─────────────────────────────────────────────────────────────────────
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    fn fixed_ts() -> DateTime<Utc> {
441        use chrono::TimeZone;
442        Utc.with_ymd_and_hms(2026, 3, 5, 10, 0, 0).unwrap()
443    }
444
445    fn fixed_id() -> String {
446        "00000000-0000-0000-0000-000000000001".to_owned()
447    }
448
449    // ── Round-trip tests ─────────────────────────────────────────────────────
450
451    #[test]
452    fn join_round_trips() {
453        let msg = Message::Join {
454            id: fixed_id(),
455            room: "r".into(),
456            user: "alice".into(),
457            ts: fixed_ts(),
458            seq: None,
459        };
460        let json = serde_json::to_string(&msg).unwrap();
461        let back: Message = serde_json::from_str(&json).unwrap();
462        assert_eq!(msg, back);
463    }
464
465    #[test]
466    fn leave_round_trips() {
467        let msg = Message::Leave {
468            id: fixed_id(),
469            room: "r".into(),
470            user: "bob".into(),
471            ts: fixed_ts(),
472            seq: None,
473        };
474        let json = serde_json::to_string(&msg).unwrap();
475        let back: Message = serde_json::from_str(&json).unwrap();
476        assert_eq!(msg, back);
477    }
478
479    #[test]
480    fn message_round_trips() {
481        let msg = Message::Message {
482            id: fixed_id(),
483            room: "r".into(),
484            user: "alice".into(),
485            ts: fixed_ts(),
486            content: "hello world".into(),
487            seq: None,
488        };
489        let json = serde_json::to_string(&msg).unwrap();
490        let back: Message = serde_json::from_str(&json).unwrap();
491        assert_eq!(msg, back);
492    }
493
494    #[test]
495    fn reply_round_trips() {
496        let msg = Message::Reply {
497            id: fixed_id(),
498            room: "r".into(),
499            user: "bob".into(),
500            ts: fixed_ts(),
501            reply_to: "ffffffff-0000-0000-0000-000000000000".into(),
502            content: "pong".into(),
503            seq: None,
504        };
505        let json = serde_json::to_string(&msg).unwrap();
506        let back: Message = serde_json::from_str(&json).unwrap();
507        assert_eq!(msg, back);
508    }
509
510    #[test]
511    fn command_round_trips() {
512        let msg = Message::Command {
513            id: fixed_id(),
514            room: "r".into(),
515            user: "alice".into(),
516            ts: fixed_ts(),
517            cmd: "claim".into(),
518            params: vec!["task-123".into(), "fix the bug".into()],
519            seq: None,
520        };
521        let json = serde_json::to_string(&msg).unwrap();
522        let back: Message = serde_json::from_str(&json).unwrap();
523        assert_eq!(msg, back);
524    }
525
526    #[test]
527    fn system_round_trips() {
528        let msg = Message::System {
529            id: fixed_id(),
530            room: "r".into(),
531            user: "broker".into(),
532            ts: fixed_ts(),
533            content: "5 users online".into(),
534            seq: None,
535        };
536        let json = serde_json::to_string(&msg).unwrap();
537        let back: Message = serde_json::from_str(&json).unwrap();
538        assert_eq!(msg, back);
539    }
540
541    // ── JSON shape tests ─────────────────────────────────────────────────────
542
543    #[test]
544    fn join_json_has_type_field_at_top_level() {
545        let msg = Message::Join {
546            id: fixed_id(),
547            room: "r".into(),
548            user: "alice".into(),
549            ts: fixed_ts(),
550            seq: None,
551        };
552        let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
553        assert_eq!(v["type"], "join");
554        assert_eq!(v["user"], "alice");
555        assert_eq!(v["room"], "r");
556        assert!(
557            v.get("content").is_none(),
558            "join should not have content field"
559        );
560    }
561
562    #[test]
563    fn message_json_has_content_at_top_level() {
564        let msg = Message::Message {
565            id: fixed_id(),
566            room: "r".into(),
567            user: "alice".into(),
568            ts: fixed_ts(),
569            content: "hi".into(),
570            seq: None,
571        };
572        let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
573        assert_eq!(v["type"], "message");
574        assert_eq!(v["content"], "hi");
575    }
576
577    #[test]
578    fn deserialize_join_from_literal() {
579        let raw = r#"{"type":"join","id":"abc","room":"myroom","user":"alice","ts":"2026-03-05T10:00:00Z"}"#;
580        let msg: Message = serde_json::from_str(raw).unwrap();
581        assert!(matches!(msg, Message::Join { .. }));
582        assert_eq!(msg.user(), "alice");
583    }
584
585    #[test]
586    fn deserialize_message_from_literal() {
587        let raw = r#"{"type":"message","id":"abc","room":"r","user":"bob","ts":"2026-03-05T10:00:00Z","content":"yo"}"#;
588        let msg: Message = serde_json::from_str(raw).unwrap();
589        assert!(matches!(&msg, Message::Message { content, .. } if content == "yo"));
590    }
591
592    #[test]
593    fn deserialize_command_with_empty_params() {
594        let raw = r#"{"type":"command","id":"x","room":"r","user":"u","ts":"2026-03-05T10:00:00Z","cmd":"status","params":[]}"#;
595        let msg: Message = serde_json::from_str(raw).unwrap();
596        assert!(
597            matches!(&msg, Message::Command { cmd, params, .. } if cmd == "status" && params.is_empty())
598        );
599    }
600
601    // ── parse_client_line tests ───────────────────────────────────────────────
602
603    #[test]
604    fn parse_plain_text_becomes_message() {
605        let msg = parse_client_line("hello there", "myroom", "alice").unwrap();
606        assert!(matches!(&msg, Message::Message { content, .. } if content == "hello there"));
607        assert_eq!(msg.user(), "alice");
608        assert_eq!(msg.room(), "myroom");
609    }
610
611    #[test]
612    fn parse_json_message_envelope() {
613        let raw = r#"{"type":"message","content":"from agent"}"#;
614        let msg = parse_client_line(raw, "r", "bot1").unwrap();
615        assert!(matches!(&msg, Message::Message { content, .. } if content == "from agent"));
616    }
617
618    #[test]
619    fn parse_json_reply_envelope() {
620        let raw = r#"{"type":"reply","reply_to":"deadbeef","content":"ack"}"#;
621        let msg = parse_client_line(raw, "r", "bot1").unwrap();
622        assert!(
623            matches!(&msg, Message::Reply { reply_to, content, .. } if reply_to == "deadbeef" && content == "ack")
624        );
625    }
626
627    #[test]
628    fn parse_json_command_envelope() {
629        let raw = r#"{"type":"command","cmd":"claim","params":["task-42"]}"#;
630        let msg = parse_client_line(raw, "r", "agent").unwrap();
631        assert!(
632            matches!(&msg, Message::Command { cmd, params, .. } if cmd == "claim" && params == &["task-42"])
633        );
634    }
635
636    #[test]
637    fn parse_invalid_json_errors() {
638        let result = parse_client_line(r#"{"type":"unknown_type"}"#, "r", "u");
639        assert!(result.is_err());
640    }
641
642    #[test]
643    fn parse_dm_envelope() {
644        let raw = r#"{"type":"dm","to":"bob","content":"hey bob"}"#;
645        let msg = parse_client_line(raw, "r", "alice").unwrap();
646        assert!(
647            matches!(&msg, Message::DirectMessage { to, content, .. } if to == "bob" && content == "hey bob")
648        );
649        assert_eq!(msg.user(), "alice");
650    }
651
652    #[test]
653    fn dm_round_trips() {
654        let msg = Message::DirectMessage {
655            id: fixed_id(),
656            room: "r".into(),
657            user: "alice".into(),
658            ts: fixed_ts(),
659            to: "bob".into(),
660            content: "secret".into(),
661            seq: None,
662        };
663        let json = serde_json::to_string(&msg).unwrap();
664        let back: Message = serde_json::from_str(&json).unwrap();
665        assert_eq!(msg, back);
666    }
667
668    #[test]
669    fn dm_json_has_type_dm() {
670        let msg = Message::DirectMessage {
671            id: fixed_id(),
672            room: "r".into(),
673            user: "alice".into(),
674            ts: fixed_ts(),
675            to: "bob".into(),
676            content: "hi".into(),
677            seq: None,
678        };
679        let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
680        assert_eq!(v["type"], "dm");
681        assert_eq!(v["to"], "bob");
682        assert_eq!(v["content"], "hi");
683    }
684
685    // ── Accessor tests ────────────────────────────────────────────────────────
686
687    #[test]
688    fn accessors_return_correct_fields() {
689        let ts = fixed_ts();
690        let msg = Message::Message {
691            id: fixed_id(),
692            room: "testroom".into(),
693            user: "carol".into(),
694            ts,
695            content: "x".into(),
696            seq: None,
697        };
698        assert_eq!(msg.id(), fixed_id());
699        assert_eq!(msg.room(), "testroom");
700        assert_eq!(msg.user(), "carol");
701        assert_eq!(msg.ts(), &fixed_ts());
702    }
703
704    // ── RoomVisibility tests ──────────────────────────────────────────────────
705
706    #[test]
707    fn room_visibility_serde_round_trip() {
708        for vis in [
709            RoomVisibility::Public,
710            RoomVisibility::Private,
711            RoomVisibility::Unlisted,
712            RoomVisibility::Dm,
713        ] {
714            let json = serde_json::to_string(&vis).unwrap();
715            let back: RoomVisibility = serde_json::from_str(&json).unwrap();
716            assert_eq!(vis, back);
717        }
718    }
719
720    #[test]
721    fn room_visibility_rename_all_snake_case() {
722        assert_eq!(
723            serde_json::to_string(&RoomVisibility::Public).unwrap(),
724            r#""public""#
725        );
726        assert_eq!(
727            serde_json::to_string(&RoomVisibility::Dm).unwrap(),
728            r#""dm""#
729        );
730    }
731
732    // ── dm_room_id tests ──────────────────────────────────────────────────────
733
734    #[test]
735    fn dm_room_id_sorts_alphabetically() {
736        assert_eq!(dm_room_id("alice", "bob"), "dm-alice-bob");
737        assert_eq!(dm_room_id("bob", "alice"), "dm-alice-bob");
738    }
739
740    #[test]
741    fn dm_room_id_same_user() {
742        // Degenerate case — should still produce a valid ID
743        assert_eq!(dm_room_id("alice", "alice"), "dm-alice-alice");
744    }
745
746    #[test]
747    fn dm_room_id_is_deterministic() {
748        let id1 = dm_room_id("r2d2", "saphire");
749        let id2 = dm_room_id("saphire", "r2d2");
750        assert_eq!(id1, id2);
751        assert_eq!(id1, "dm-r2d2-saphire");
752    }
753
754    // ── RoomConfig tests ──────────────────────────────────────────────────────
755
756    #[test]
757    fn room_config_public_defaults() {
758        let config = RoomConfig::public("alice");
759        assert_eq!(config.visibility, RoomVisibility::Public);
760        assert!(config.max_members.is_none());
761        assert!(config.invite_list.is_empty());
762        assert_eq!(config.created_by, "alice");
763    }
764
765    #[test]
766    fn room_config_dm_has_two_users() {
767        let config = RoomConfig::dm("alice", "bob");
768        assert_eq!(config.visibility, RoomVisibility::Dm);
769        assert_eq!(config.max_members, Some(2));
770        assert!(config.invite_list.contains("alice"));
771        assert!(config.invite_list.contains("bob"));
772        assert_eq!(config.invite_list.len(), 2);
773    }
774
775    #[test]
776    fn room_config_serde_round_trip() {
777        let config = RoomConfig::dm("alice", "bob");
778        let json = serde_json::to_string(&config).unwrap();
779        let back: RoomConfig = serde_json::from_str(&json).unwrap();
780        assert_eq!(back.visibility, RoomVisibility::Dm);
781        assert_eq!(back.max_members, Some(2));
782        assert!(back.invite_list.contains("alice"));
783        assert!(back.invite_list.contains("bob"));
784    }
785
786    // ── RoomListEntry tests ───────────────────────────────────────────────────
787
788    #[test]
789    fn room_list_entry_serde_round_trip() {
790        let entry = RoomListEntry {
791            room_id: "dev-chat".into(),
792            visibility: RoomVisibility::Public,
793            member_count: 5,
794            created_by: "alice".into(),
795        };
796        let json = serde_json::to_string(&entry).unwrap();
797        let back: RoomListEntry = serde_json::from_str(&json).unwrap();
798        assert_eq!(back.room_id, "dev-chat");
799        assert_eq!(back.visibility, RoomVisibility::Public);
800        assert_eq!(back.member_count, 5);
801    }
802
803    // ── parse_mentions tests ────────────────────────────────────────────────
804
805    #[test]
806    fn parse_mentions_single() {
807        assert_eq!(parse_mentions("hello @alice"), vec!["alice"]);
808    }
809
810    #[test]
811    fn parse_mentions_multiple() {
812        assert_eq!(
813            parse_mentions("@alice and @bob should see this"),
814            vec!["alice", "bob"]
815        );
816    }
817
818    #[test]
819    fn parse_mentions_at_start() {
820        assert_eq!(parse_mentions("@alice hello"), vec!["alice"]);
821    }
822
823    #[test]
824    fn parse_mentions_at_end() {
825        assert_eq!(parse_mentions("hello @alice"), vec!["alice"]);
826    }
827
828    #[test]
829    fn parse_mentions_with_hyphens_and_underscores() {
830        assert_eq!(parse_mentions("cc @my-agent_2"), vec!["my-agent_2"]);
831    }
832
833    #[test]
834    fn parse_mentions_deduplicates() {
835        assert_eq!(parse_mentions("@alice @bob @alice"), vec!["alice", "bob"]);
836    }
837
838    #[test]
839    fn parse_mentions_skips_email() {
840        assert!(parse_mentions("send to user@example.com").is_empty());
841    }
842
843    #[test]
844    fn parse_mentions_skips_bare_at() {
845        assert!(parse_mentions("@ alone").is_empty());
846    }
847
848    #[test]
849    fn parse_mentions_empty_content() {
850        assert!(parse_mentions("").is_empty());
851    }
852
853    #[test]
854    fn parse_mentions_no_mentions() {
855        assert!(parse_mentions("just a normal message").is_empty());
856    }
857
858    #[test]
859    fn parse_mentions_punctuation_after_username() {
860        assert_eq!(parse_mentions("hey @alice, what's up?"), vec!["alice"]);
861    }
862
863    #[test]
864    fn parse_mentions_multiple_at_signs() {
865        // user@@foo — second @ is preceded by non-whitespace, so skipped
866        assert_eq!(parse_mentions("@alice@@bob"), vec!["alice"]);
867    }
868
869    // ── content() and mentions() method tests ───────────────────────────────
870
871    #[test]
872    fn message_content_returns_text() {
873        let msg = make_message("r", "alice", "hello @bob");
874        assert_eq!(msg.content(), Some("hello @bob"));
875    }
876
877    #[test]
878    fn join_content_returns_none() {
879        let msg = make_join("r", "alice");
880        assert!(msg.content().is_none());
881    }
882
883    #[test]
884    fn message_mentions_extracts_usernames() {
885        let msg = make_message("r", "alice", "hey @bob and @carol");
886        assert_eq!(msg.mentions(), vec!["bob", "carol"]);
887    }
888
889    #[test]
890    fn join_mentions_returns_empty() {
891        let msg = make_join("r", "alice");
892        assert!(msg.mentions().is_empty());
893    }
894
895    #[test]
896    fn dm_mentions_works() {
897        let msg = make_dm("r", "alice", "bob", "cc @carol on this");
898        assert_eq!(msg.mentions(), vec!["carol"]);
899    }
900
901    #[test]
902    fn reply_content_returns_text() {
903        let msg = make_reply("r", "alice", "msg-1", "@bob noted");
904        assert_eq!(msg.content(), Some("@bob noted"));
905        assert_eq!(msg.mentions(), vec!["bob"]);
906    }
907}