Skip to main content

room_protocol/
lib.rs

1use std::collections::HashSet;
2use std::fmt;
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8/// Error returned when constructing a DM room ID with invalid inputs.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum DmRoomError {
11    /// Both usernames are the same — a DM requires two distinct users.
12    SameUser(String),
13}
14
15impl fmt::Display for DmRoomError {
16    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17        match self {
18            DmRoomError::SameUser(user) => {
19                write!(f, "cannot create DM room: both users are '{user}'")
20            }
21        }
22    }
23}
24
25impl std::error::Error for DmRoomError {}
26
27/// Visibility level for a room, controlling who can discover and join it.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum RoomVisibility {
31    /// Anyone can discover and join.
32    Public,
33    /// Discoverable in listings but requires invite to join.
34    Private,
35    /// Not discoverable; join requires knowing room ID + invite.
36    Unlisted,
37    /// Private 2-person room, auto-created by `/dm` command.
38    Dm,
39}
40
41/// Configuration for a room's access controls and metadata.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct RoomConfig {
44    pub visibility: RoomVisibility,
45    /// Maximum number of members. `None` = unlimited.
46    pub max_members: Option<usize>,
47    /// Usernames allowed to join (for private/unlisted/dm rooms).
48    pub invite_list: HashSet<String>,
49    /// Username of the room creator.
50    pub created_by: String,
51    /// ISO 8601 creation timestamp.
52    pub created_at: String,
53}
54
55impl RoomConfig {
56    /// Create a default public room config.
57    pub fn public(created_by: &str) -> Self {
58        Self {
59            visibility: RoomVisibility::Public,
60            max_members: None,
61            invite_list: HashSet::new(),
62            created_by: created_by.to_owned(),
63            created_at: Utc::now().to_rfc3339(),
64        }
65    }
66
67    /// Create a DM room config for two users.
68    pub fn dm(user_a: &str, user_b: &str) -> Self {
69        let mut invite_list = HashSet::new();
70        invite_list.insert(user_a.to_owned());
71        invite_list.insert(user_b.to_owned());
72        Self {
73            visibility: RoomVisibility::Dm,
74            max_members: Some(2),
75            invite_list,
76            created_by: user_a.to_owned(),
77            created_at: Utc::now().to_rfc3339(),
78        }
79    }
80}
81
82/// Compute the deterministic room ID for a DM between two users.
83///
84/// Sorts usernames alphabetically so `/dm alice` from bob and `/dm bob` from
85/// alice both resolve to the same room.
86///
87/// # Errors
88///
89/// Returns [`DmRoomError::SameUser`] if both usernames are identical.
90pub fn dm_room_id(user_a: &str, user_b: &str) -> Result<String, DmRoomError> {
91    if user_a == user_b {
92        return Err(DmRoomError::SameUser(user_a.to_owned()));
93    }
94    let (first, second) = if user_a < user_b {
95        (user_a, user_b)
96    } else {
97        (user_b, user_a)
98    };
99    Ok(format!("dm-{first}-{second}"))
100}
101
102/// Check whether a room ID represents a DM room.
103///
104/// DM room IDs follow the pattern `dm-<user_a>-<user_b>` where usernames are
105/// sorted alphabetically.
106pub fn is_dm_room(room_id: &str) -> bool {
107    room_id.starts_with("dm-") && room_id.matches('-').count() >= 2
108}
109
110/// Subscription tier for a user's relationship with a room.
111///
112/// Controls what messages appear in the user's default stream:
113/// - `Full` — all messages from the room
114/// - `MentionsOnly` — only messages that @mention the user
115/// - `Unsubscribed` — excluded from the default stream (still queryable with `--public`)
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
117#[serde(rename_all = "snake_case")]
118pub enum SubscriptionTier {
119    Full,
120    MentionsOnly,
121    Unsubscribed,
122}
123
124impl std::fmt::Display for SubscriptionTier {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        match self {
127            Self::Full => write!(f, "full"),
128            Self::MentionsOnly => write!(f, "mentions_only"),
129            Self::Unsubscribed => write!(f, "unsubscribed"),
130        }
131    }
132}
133
134impl std::str::FromStr for SubscriptionTier {
135    type Err = String;
136
137    fn from_str(s: &str) -> Result<Self, Self::Err> {
138        match s {
139            "full" => Ok(Self::Full),
140            "mentions_only" | "mentions-only" | "mentions" => Ok(Self::MentionsOnly),
141            "unsubscribed" | "none" => Ok(Self::Unsubscribed),
142            other => Err(format!(
143                "unknown subscription tier '{other}'; expected full, mentions_only, or unsubscribed"
144            )),
145        }
146    }
147}
148
149/// Entry returned by room listing (discovery).
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct RoomListEntry {
152    pub room_id: String,
153    pub visibility: RoomVisibility,
154    pub member_count: usize,
155    pub created_by: String,
156}
157
158/// Wire format for all messages stored in the chat file and sent over the socket.
159///
160/// Uses `#[serde(tag = "type")]` internally-tagged enum **without** `#[serde(flatten)]`
161/// to avoid the serde flatten + internally-tagged footgun that breaks deserialization.
162/// Every variant carries its own id/room/user/ts fields.
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164#[serde(tag = "type", rename_all = "snake_case")]
165pub enum Message {
166    Join {
167        id: String,
168        room: String,
169        user: String,
170        ts: DateTime<Utc>,
171        #[serde(default, skip_serializing_if = "Option::is_none")]
172        seq: Option<u64>,
173    },
174    Leave {
175        id: String,
176        room: String,
177        user: String,
178        ts: DateTime<Utc>,
179        #[serde(default, skip_serializing_if = "Option::is_none")]
180        seq: Option<u64>,
181    },
182    Message {
183        id: String,
184        room: String,
185        user: String,
186        ts: DateTime<Utc>,
187        #[serde(default, skip_serializing_if = "Option::is_none")]
188        seq: Option<u64>,
189        content: String,
190    },
191    Reply {
192        id: String,
193        room: String,
194        user: String,
195        ts: DateTime<Utc>,
196        #[serde(default, skip_serializing_if = "Option::is_none")]
197        seq: Option<u64>,
198        reply_to: String,
199        content: String,
200    },
201    Command {
202        id: String,
203        room: String,
204        user: String,
205        ts: DateTime<Utc>,
206        #[serde(default, skip_serializing_if = "Option::is_none")]
207        seq: Option<u64>,
208        cmd: String,
209        params: Vec<String>,
210    },
211    System {
212        id: String,
213        room: String,
214        user: String,
215        ts: DateTime<Utc>,
216        #[serde(default, skip_serializing_if = "Option::is_none")]
217        seq: Option<u64>,
218        content: String,
219    },
220    /// A private direct message. Delivered only to sender, recipient, and the
221    /// broker host. Always written to the chat history file.
222    #[serde(rename = "dm")]
223    DirectMessage {
224        id: String,
225        room: String,
226        /// Sender username (set by the broker).
227        user: String,
228        ts: DateTime<Utc>,
229        #[serde(default, skip_serializing_if = "Option::is_none")]
230        seq: Option<u64>,
231        /// Recipient username.
232        to: String,
233        content: String,
234    },
235}
236
237impl Message {
238    pub fn id(&self) -> &str {
239        match self {
240            Self::Join { id, .. }
241            | Self::Leave { id, .. }
242            | Self::Message { id, .. }
243            | Self::Reply { id, .. }
244            | Self::Command { id, .. }
245            | Self::System { id, .. }
246            | Self::DirectMessage { id, .. } => id,
247        }
248    }
249
250    pub fn room(&self) -> &str {
251        match self {
252            Self::Join { room, .. }
253            | Self::Leave { room, .. }
254            | Self::Message { room, .. }
255            | Self::Reply { room, .. }
256            | Self::Command { room, .. }
257            | Self::System { room, .. }
258            | Self::DirectMessage { room, .. } => room,
259        }
260    }
261
262    pub fn user(&self) -> &str {
263        match self {
264            Self::Join { user, .. }
265            | Self::Leave { user, .. }
266            | Self::Message { user, .. }
267            | Self::Reply { user, .. }
268            | Self::Command { user, .. }
269            | Self::System { user, .. }
270            | Self::DirectMessage { user, .. } => user,
271        }
272    }
273
274    pub fn ts(&self) -> &DateTime<Utc> {
275        match self {
276            Self::Join { ts, .. }
277            | Self::Leave { ts, .. }
278            | Self::Message { ts, .. }
279            | Self::Reply { ts, .. }
280            | Self::Command { ts, .. }
281            | Self::System { ts, .. }
282            | Self::DirectMessage { ts, .. } => ts,
283        }
284    }
285
286    /// Returns the sequence number assigned by the broker, or `None` for
287    /// messages loaded from history files that predate this feature.
288    pub fn seq(&self) -> Option<u64> {
289        match self {
290            Self::Join { seq, .. }
291            | Self::Leave { seq, .. }
292            | Self::Message { seq, .. }
293            | Self::Reply { seq, .. }
294            | Self::Command { seq, .. }
295            | Self::System { seq, .. }
296            | Self::DirectMessage { seq, .. } => *seq,
297        }
298    }
299
300    /// Returns the text content of this message, or `None` for variants without content
301    /// (Join, Leave, Command).
302    pub fn content(&self) -> Option<&str> {
303        match self {
304            Self::Message { content, .. }
305            | Self::Reply { content, .. }
306            | Self::System { content, .. }
307            | Self::DirectMessage { content, .. } => Some(content),
308            Self::Join { .. } | Self::Leave { .. } | Self::Command { .. } => None,
309        }
310    }
311
312    /// Extract @mentions from this message's content.
313    ///
314    /// Returns an empty vec for variants without content (Join, Leave, Command)
315    /// or content with no @mentions.
316    pub fn mentions(&self) -> Vec<String> {
317        match self.content() {
318            Some(content) => parse_mentions(content),
319            None => Vec::new(),
320        }
321    }
322
323    /// Returns `true` if `viewer` is allowed to see this message.
324    ///
325    /// All non-DM variants are visible to everyone. A [`Message::DirectMessage`]
326    /// is visible only to the sender (`user`), the recipient (`to`), and the
327    /// room host (when `host == Some(viewer)`).
328    pub fn is_visible_to(&self, viewer: &str, host: Option<&str>) -> bool {
329        match self {
330            Self::DirectMessage { user, to, .. } => {
331                viewer == user || viewer == to.as_str() || host == Some(viewer)
332            }
333            _ => true,
334        }
335    }
336
337    /// Assign a broker-issued sequence number to this message.
338    pub fn set_seq(&mut self, seq: u64) {
339        let n = Some(seq);
340        match self {
341            Self::Join { seq, .. } => *seq = n,
342            Self::Leave { seq, .. } => *seq = n,
343            Self::Message { seq, .. } => *seq = n,
344            Self::Reply { seq, .. } => *seq = n,
345            Self::Command { seq, .. } => *seq = n,
346            Self::System { seq, .. } => *seq = n,
347            Self::DirectMessage { seq, .. } => *seq = n,
348        }
349    }
350}
351
352// ── Constructors ─────────────────────────────────────────────────────────────
353
354fn new_id() -> String {
355    Uuid::new_v4().to_string()
356}
357
358pub fn make_join(room: &str, user: &str) -> Message {
359    Message::Join {
360        id: new_id(),
361        room: room.to_owned(),
362        user: user.to_owned(),
363        ts: Utc::now(),
364        seq: None,
365    }
366}
367
368pub fn make_leave(room: &str, user: &str) -> Message {
369    Message::Leave {
370        id: new_id(),
371        room: room.to_owned(),
372        user: user.to_owned(),
373        ts: Utc::now(),
374        seq: None,
375    }
376}
377
378pub fn make_message(room: &str, user: &str, content: impl Into<String>) -> Message {
379    Message::Message {
380        id: new_id(),
381        room: room.to_owned(),
382        user: user.to_owned(),
383        ts: Utc::now(),
384        content: content.into(),
385        seq: None,
386    }
387}
388
389pub fn make_reply(
390    room: &str,
391    user: &str,
392    reply_to: impl Into<String>,
393    content: impl Into<String>,
394) -> Message {
395    Message::Reply {
396        id: new_id(),
397        room: room.to_owned(),
398        user: user.to_owned(),
399        ts: Utc::now(),
400        reply_to: reply_to.into(),
401        content: content.into(),
402        seq: None,
403    }
404}
405
406pub fn make_command(
407    room: &str,
408    user: &str,
409    cmd: impl Into<String>,
410    params: Vec<String>,
411) -> Message {
412    Message::Command {
413        id: new_id(),
414        room: room.to_owned(),
415        user: user.to_owned(),
416        ts: Utc::now(),
417        cmd: cmd.into(),
418        params,
419        seq: None,
420    }
421}
422
423pub fn make_system(room: &str, user: &str, content: impl Into<String>) -> Message {
424    Message::System {
425        id: new_id(),
426        room: room.to_owned(),
427        user: user.to_owned(),
428        ts: Utc::now(),
429        content: content.into(),
430        seq: None,
431    }
432}
433
434pub fn make_dm(room: &str, user: &str, to: &str, content: impl Into<String>) -> Message {
435    Message::DirectMessage {
436        id: new_id(),
437        room: room.to_owned(),
438        user: user.to_owned(),
439        ts: Utc::now(),
440        to: to.to_owned(),
441        content: content.into(),
442        seq: None,
443    }
444}
445
446/// Extract @mentions from message content.
447///
448/// Matches `@username` patterns where usernames can contain alphanumerics, hyphens,
449/// and underscores. Stops at whitespace, punctuation (except `-` and `_`), or end of
450/// string. Skips email-like patterns (`user@domain`) by requiring the `@` to be at
451/// the start of the string or preceded by whitespace.
452///
453/// Returns a deduplicated list of mentioned usernames (without the `@` prefix),
454/// preserving first-occurrence order.
455pub fn parse_mentions(content: &str) -> Vec<String> {
456    let mut mentions = Vec::new();
457    let mut seen = HashSet::new();
458
459    for (i, _) in content.match_indices('@') {
460        // Skip if preceded by a non-whitespace char (email-like pattern)
461        if i > 0 {
462            let prev = content.as_bytes()[i - 1];
463            if !prev.is_ascii_whitespace() {
464                continue;
465            }
466        }
467
468        // Extract username chars after @
469        let rest = &content[i + 1..];
470        let end = rest
471            .find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
472            .unwrap_or(rest.len());
473        let username = &rest[..end];
474
475        if !username.is_empty() && seen.insert(username.to_owned()) {
476            mentions.push(username.to_owned());
477        }
478    }
479
480    mentions
481}
482
483/// Format a human-readable message ID from a room ID and sequence number.
484///
485/// The canonical format is `"<room>:<seq>"`, e.g. `"agent-room:42"`. This is a
486/// display-only identifier used by `--from`, `--to`, and `--id` flags. The wire
487/// format keeps `room` and `seq` as separate fields and never stores this string.
488pub fn format_message_id(room: &str, seq: u64) -> String {
489    format!("{room}:{seq}")
490}
491
492/// Parse a human-readable message ID back into `(room_id, seq)`.
493///
494/// Expects the format `"<room>:<seq>"` produced by [`format_message_id`].
495/// Splits on the **last** colon so room IDs that themselves contain colons are
496/// handled correctly (e.g. `"namespace:room:42"` → `("namespace:room", 42)`).
497///
498/// Returns `Err(String)` if the input has no colon or if the part after the
499/// last colon cannot be parsed as a `u64`.
500pub fn parse_message_id(id: &str) -> Result<(String, u64), String> {
501    let colon = id
502        .rfind(':')
503        .ok_or_else(|| format!("no colon in message ID: {id:?}"))?;
504    let room = &id[..colon];
505    let seq_str = &id[colon + 1..];
506    let seq = seq_str
507        .parse::<u64>()
508        .map_err(|_| format!("invalid sequence number in message ID: {id:?}"))?;
509    Ok((room.to_owned(), seq))
510}
511
512/// Parse a raw line from a client socket.
513/// JSON envelope → Message with broker-assigned id/room/ts.
514/// Plain text → Message::Message with broker-assigned metadata.
515pub fn parse_client_line(raw: &str, room: &str, user: &str) -> Result<Message, serde_json::Error> {
516    #[derive(Deserialize)]
517    #[serde(tag = "type", rename_all = "snake_case")]
518    enum Envelope {
519        Message {
520            content: String,
521        },
522        Reply {
523            reply_to: String,
524            content: String,
525        },
526        Command {
527            cmd: String,
528            params: Vec<String>,
529        },
530        #[serde(rename = "dm")]
531        Dm {
532            to: String,
533            content: String,
534        },
535    }
536
537    if raw.starts_with('{') {
538        let env: Envelope = serde_json::from_str(raw)?;
539        let msg = match env {
540            Envelope::Message { content } => make_message(room, user, content),
541            Envelope::Reply { reply_to, content } => make_reply(room, user, reply_to, content),
542            Envelope::Command { cmd, params } => make_command(room, user, cmd, params),
543            Envelope::Dm { to, content } => make_dm(room, user, &to, content),
544        };
545        Ok(msg)
546    } else {
547        Ok(make_message(room, user, raw))
548    }
549}
550
551// ── Tests ─────────────────────────────────────────────────────────────────────
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    fn fixed_ts() -> DateTime<Utc> {
558        use chrono::TimeZone;
559        Utc.with_ymd_and_hms(2026, 3, 5, 10, 0, 0).unwrap()
560    }
561
562    fn fixed_id() -> String {
563        "00000000-0000-0000-0000-000000000001".to_owned()
564    }
565
566    // ── Round-trip tests ─────────────────────────────────────────────────────
567
568    #[test]
569    fn join_round_trips() {
570        let msg = Message::Join {
571            id: fixed_id(),
572            room: "r".into(),
573            user: "alice".into(),
574            ts: fixed_ts(),
575            seq: None,
576        };
577        let json = serde_json::to_string(&msg).unwrap();
578        let back: Message = serde_json::from_str(&json).unwrap();
579        assert_eq!(msg, back);
580    }
581
582    #[test]
583    fn leave_round_trips() {
584        let msg = Message::Leave {
585            id: fixed_id(),
586            room: "r".into(),
587            user: "bob".into(),
588            ts: fixed_ts(),
589            seq: None,
590        };
591        let json = serde_json::to_string(&msg).unwrap();
592        let back: Message = serde_json::from_str(&json).unwrap();
593        assert_eq!(msg, back);
594    }
595
596    #[test]
597    fn message_round_trips() {
598        let msg = Message::Message {
599            id: fixed_id(),
600            room: "r".into(),
601            user: "alice".into(),
602            ts: fixed_ts(),
603            content: "hello world".into(),
604            seq: None,
605        };
606        let json = serde_json::to_string(&msg).unwrap();
607        let back: Message = serde_json::from_str(&json).unwrap();
608        assert_eq!(msg, back);
609    }
610
611    #[test]
612    fn reply_round_trips() {
613        let msg = Message::Reply {
614            id: fixed_id(),
615            room: "r".into(),
616            user: "bob".into(),
617            ts: fixed_ts(),
618            reply_to: "ffffffff-0000-0000-0000-000000000000".into(),
619            content: "pong".into(),
620            seq: None,
621        };
622        let json = serde_json::to_string(&msg).unwrap();
623        let back: Message = serde_json::from_str(&json).unwrap();
624        assert_eq!(msg, back);
625    }
626
627    #[test]
628    fn command_round_trips() {
629        let msg = Message::Command {
630            id: fixed_id(),
631            room: "r".into(),
632            user: "alice".into(),
633            ts: fixed_ts(),
634            cmd: "claim".into(),
635            params: vec!["task-123".into(), "fix the bug".into()],
636            seq: None,
637        };
638        let json = serde_json::to_string(&msg).unwrap();
639        let back: Message = serde_json::from_str(&json).unwrap();
640        assert_eq!(msg, back);
641    }
642
643    #[test]
644    fn system_round_trips() {
645        let msg = Message::System {
646            id: fixed_id(),
647            room: "r".into(),
648            user: "broker".into(),
649            ts: fixed_ts(),
650            content: "5 users online".into(),
651            seq: None,
652        };
653        let json = serde_json::to_string(&msg).unwrap();
654        let back: Message = serde_json::from_str(&json).unwrap();
655        assert_eq!(msg, back);
656    }
657
658    // ── JSON shape tests ─────────────────────────────────────────────────────
659
660    #[test]
661    fn join_json_has_type_field_at_top_level() {
662        let msg = Message::Join {
663            id: fixed_id(),
664            room: "r".into(),
665            user: "alice".into(),
666            ts: fixed_ts(),
667            seq: None,
668        };
669        let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
670        assert_eq!(v["type"], "join");
671        assert_eq!(v["user"], "alice");
672        assert_eq!(v["room"], "r");
673        assert!(
674            v.get("content").is_none(),
675            "join should not have content field"
676        );
677    }
678
679    #[test]
680    fn message_json_has_content_at_top_level() {
681        let msg = Message::Message {
682            id: fixed_id(),
683            room: "r".into(),
684            user: "alice".into(),
685            ts: fixed_ts(),
686            content: "hi".into(),
687            seq: None,
688        };
689        let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
690        assert_eq!(v["type"], "message");
691        assert_eq!(v["content"], "hi");
692    }
693
694    #[test]
695    fn deserialize_join_from_literal() {
696        let raw = r#"{"type":"join","id":"abc","room":"myroom","user":"alice","ts":"2026-03-05T10:00:00Z"}"#;
697        let msg: Message = serde_json::from_str(raw).unwrap();
698        assert!(matches!(msg, Message::Join { .. }));
699        assert_eq!(msg.user(), "alice");
700    }
701
702    #[test]
703    fn deserialize_message_from_literal() {
704        let raw = r#"{"type":"message","id":"abc","room":"r","user":"bob","ts":"2026-03-05T10:00:00Z","content":"yo"}"#;
705        let msg: Message = serde_json::from_str(raw).unwrap();
706        assert!(matches!(&msg, Message::Message { content, .. } if content == "yo"));
707    }
708
709    #[test]
710    fn deserialize_command_with_empty_params() {
711        let raw = r#"{"type":"command","id":"x","room":"r","user":"u","ts":"2026-03-05T10:00:00Z","cmd":"status","params":[]}"#;
712        let msg: Message = serde_json::from_str(raw).unwrap();
713        assert!(
714            matches!(&msg, Message::Command { cmd, params, .. } if cmd == "status" && params.is_empty())
715        );
716    }
717
718    // ── parse_client_line tests ───────────────────────────────────────────────
719
720    #[test]
721    fn parse_plain_text_becomes_message() {
722        let msg = parse_client_line("hello there", "myroom", "alice").unwrap();
723        assert!(matches!(&msg, Message::Message { content, .. } if content == "hello there"));
724        assert_eq!(msg.user(), "alice");
725        assert_eq!(msg.room(), "myroom");
726    }
727
728    #[test]
729    fn parse_json_message_envelope() {
730        let raw = r#"{"type":"message","content":"from agent"}"#;
731        let msg = parse_client_line(raw, "r", "bot1").unwrap();
732        assert!(matches!(&msg, Message::Message { content, .. } if content == "from agent"));
733    }
734
735    #[test]
736    fn parse_json_reply_envelope() {
737        let raw = r#"{"type":"reply","reply_to":"deadbeef","content":"ack"}"#;
738        let msg = parse_client_line(raw, "r", "bot1").unwrap();
739        assert!(
740            matches!(&msg, Message::Reply { reply_to, content, .. } if reply_to == "deadbeef" && content == "ack")
741        );
742    }
743
744    #[test]
745    fn parse_json_command_envelope() {
746        let raw = r#"{"type":"command","cmd":"claim","params":["task-42"]}"#;
747        let msg = parse_client_line(raw, "r", "agent").unwrap();
748        assert!(
749            matches!(&msg, Message::Command { cmd, params, .. } if cmd == "claim" && params == &["task-42"])
750        );
751    }
752
753    #[test]
754    fn parse_invalid_json_errors() {
755        let result = parse_client_line(r#"{"type":"unknown_type"}"#, "r", "u");
756        assert!(result.is_err());
757    }
758
759    #[test]
760    fn parse_dm_envelope() {
761        let raw = r#"{"type":"dm","to":"bob","content":"hey bob"}"#;
762        let msg = parse_client_line(raw, "r", "alice").unwrap();
763        assert!(
764            matches!(&msg, Message::DirectMessage { to, content, .. } if to == "bob" && content == "hey bob")
765        );
766        assert_eq!(msg.user(), "alice");
767    }
768
769    #[test]
770    fn dm_round_trips() {
771        let msg = Message::DirectMessage {
772            id: fixed_id(),
773            room: "r".into(),
774            user: "alice".into(),
775            ts: fixed_ts(),
776            to: "bob".into(),
777            content: "secret".into(),
778            seq: None,
779        };
780        let json = serde_json::to_string(&msg).unwrap();
781        let back: Message = serde_json::from_str(&json).unwrap();
782        assert_eq!(msg, back);
783    }
784
785    #[test]
786    fn dm_json_has_type_dm() {
787        let msg = Message::DirectMessage {
788            id: fixed_id(),
789            room: "r".into(),
790            user: "alice".into(),
791            ts: fixed_ts(),
792            to: "bob".into(),
793            content: "hi".into(),
794            seq: None,
795        };
796        let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
797        assert_eq!(v["type"], "dm");
798        assert_eq!(v["to"], "bob");
799        assert_eq!(v["content"], "hi");
800    }
801
802    // ── is_visible_to tests ───────────────────────────────────────────────────
803
804    fn make_test_dm(from: &str, to: &str) -> Message {
805        Message::DirectMessage {
806            id: fixed_id(),
807            room: "r".into(),
808            user: from.into(),
809            ts: fixed_ts(),
810            seq: None,
811            to: to.into(),
812            content: "secret".into(),
813        }
814    }
815
816    #[test]
817    fn dm_visible_to_sender() {
818        let msg = make_test_dm("alice", "bob");
819        assert!(msg.is_visible_to("alice", None));
820    }
821
822    #[test]
823    fn dm_visible_to_recipient() {
824        let msg = make_test_dm("alice", "bob");
825        assert!(msg.is_visible_to("bob", None));
826    }
827
828    #[test]
829    fn dm_visible_to_host() {
830        let msg = make_test_dm("alice", "bob");
831        assert!(msg.is_visible_to("carol", Some("carol")));
832    }
833
834    #[test]
835    fn dm_hidden_from_non_participant() {
836        let msg = make_test_dm("alice", "bob");
837        assert!(!msg.is_visible_to("carol", None));
838    }
839
840    #[test]
841    fn dm_non_participant_not_elevated_by_different_host() {
842        let msg = make_test_dm("alice", "bob");
843        assert!(!msg.is_visible_to("carol", Some("dave")));
844    }
845
846    #[test]
847    fn non_dm_always_visible() {
848        let msg = make_message("r", "alice", "hello");
849        assert!(msg.is_visible_to("bob", None));
850        assert!(msg.is_visible_to("carol", Some("dave")));
851    }
852
853    #[test]
854    fn join_always_visible() {
855        let msg = make_join("r", "alice");
856        assert!(msg.is_visible_to("bob", None));
857    }
858
859    // ── Accessor tests ────────────────────────────────────────────────────────
860
861    #[test]
862    fn accessors_return_correct_fields() {
863        let ts = fixed_ts();
864        let msg = Message::Message {
865            id: fixed_id(),
866            room: "testroom".into(),
867            user: "carol".into(),
868            ts,
869            content: "x".into(),
870            seq: None,
871        };
872        assert_eq!(msg.id(), fixed_id());
873        assert_eq!(msg.room(), "testroom");
874        assert_eq!(msg.user(), "carol");
875        assert_eq!(msg.ts(), &fixed_ts());
876    }
877
878    // ── RoomVisibility tests ──────────────────────────────────────────────────
879
880    #[test]
881    fn room_visibility_serde_round_trip() {
882        for vis in [
883            RoomVisibility::Public,
884            RoomVisibility::Private,
885            RoomVisibility::Unlisted,
886            RoomVisibility::Dm,
887        ] {
888            let json = serde_json::to_string(&vis).unwrap();
889            let back: RoomVisibility = serde_json::from_str(&json).unwrap();
890            assert_eq!(vis, back);
891        }
892    }
893
894    #[test]
895    fn room_visibility_rename_all_snake_case() {
896        assert_eq!(
897            serde_json::to_string(&RoomVisibility::Public).unwrap(),
898            r#""public""#
899        );
900        assert_eq!(
901            serde_json::to_string(&RoomVisibility::Dm).unwrap(),
902            r#""dm""#
903        );
904    }
905
906    // ── dm_room_id tests ──────────────────────────────────────────────────────
907
908    #[test]
909    fn dm_room_id_sorts_alphabetically() {
910        assert_eq!(dm_room_id("alice", "bob").unwrap(), "dm-alice-bob");
911        assert_eq!(dm_room_id("bob", "alice").unwrap(), "dm-alice-bob");
912    }
913
914    #[test]
915    fn dm_room_id_same_user_errors() {
916        let err = dm_room_id("alice", "alice").unwrap_err();
917        assert_eq!(err, DmRoomError::SameUser("alice".to_owned()));
918        assert_eq!(
919            err.to_string(),
920            "cannot create DM room: both users are 'alice'"
921        );
922    }
923
924    #[test]
925    fn dm_room_id_is_deterministic() {
926        let id1 = dm_room_id("r2d2", "saphire").unwrap();
927        let id2 = dm_room_id("saphire", "r2d2").unwrap();
928        assert_eq!(id1, id2);
929        assert_eq!(id1, "dm-r2d2-saphire");
930    }
931
932    #[test]
933    fn dm_room_id_case_sensitive() {
934        let id1 = dm_room_id("Alice", "bob").unwrap();
935        let id2 = dm_room_id("alice", "bob").unwrap();
936        // Uppercase sorts before lowercase in ASCII
937        assert_eq!(id1, "dm-Alice-bob");
938        assert_eq!(id2, "dm-alice-bob");
939        assert_ne!(id1, id2);
940    }
941
942    #[test]
943    fn dm_room_id_with_hyphens_in_usernames() {
944        let id = dm_room_id("my-agent", "your-bot").unwrap();
945        assert_eq!(id, "dm-my-agent-your-bot");
946    }
947
948    // ── is_dm_room tests ─────────────────────────────────────────────────────
949
950    #[test]
951    fn is_dm_room_identifies_dm_rooms() {
952        assert!(is_dm_room("dm-alice-bob"));
953        assert!(is_dm_room("dm-r2d2-saphire"));
954    }
955
956    #[test]
957    fn is_dm_room_rejects_non_dm_rooms() {
958        assert!(!is_dm_room("agent-room-2"));
959        assert!(!is_dm_room("dev-chat"));
960        assert!(!is_dm_room("dm"));
961        assert!(!is_dm_room("dm-"));
962        assert!(!is_dm_room(""));
963    }
964
965    #[test]
966    fn is_dm_room_handles_edge_cases() {
967        // A room starting with "dm-" but having no second hyphen
968        assert!(!is_dm_room("dm-onlyoneuser"));
969        // Hyphenated usernames create more dashes — still valid
970        assert!(is_dm_room("dm-my-agent-your-bot"));
971    }
972
973    // ── DmRoomError tests ────────────────────────────────────────────────────
974
975    #[test]
976    fn dm_room_error_display() {
977        let err = DmRoomError::SameUser("bb".to_owned());
978        assert_eq!(
979            err.to_string(),
980            "cannot create DM room: both users are 'bb'"
981        );
982    }
983
984    #[test]
985    fn dm_room_error_is_send_sync() {
986        fn assert_send_sync<T: Send + Sync>() {}
987        assert_send_sync::<DmRoomError>();
988    }
989
990    // ── RoomConfig tests ──────────────────────────────────────────────────────
991
992    #[test]
993    fn room_config_public_defaults() {
994        let config = RoomConfig::public("alice");
995        assert_eq!(config.visibility, RoomVisibility::Public);
996        assert!(config.max_members.is_none());
997        assert!(config.invite_list.is_empty());
998        assert_eq!(config.created_by, "alice");
999    }
1000
1001    #[test]
1002    fn room_config_dm_has_two_users() {
1003        let config = RoomConfig::dm("alice", "bob");
1004        assert_eq!(config.visibility, RoomVisibility::Dm);
1005        assert_eq!(config.max_members, Some(2));
1006        assert!(config.invite_list.contains("alice"));
1007        assert!(config.invite_list.contains("bob"));
1008        assert_eq!(config.invite_list.len(), 2);
1009    }
1010
1011    #[test]
1012    fn room_config_serde_round_trip() {
1013        let config = RoomConfig::dm("alice", "bob");
1014        let json = serde_json::to_string(&config).unwrap();
1015        let back: RoomConfig = serde_json::from_str(&json).unwrap();
1016        assert_eq!(back.visibility, RoomVisibility::Dm);
1017        assert_eq!(back.max_members, Some(2));
1018        assert!(back.invite_list.contains("alice"));
1019        assert!(back.invite_list.contains("bob"));
1020    }
1021
1022    // ── RoomListEntry tests ───────────────────────────────────────────────────
1023
1024    #[test]
1025    fn room_list_entry_serde_round_trip() {
1026        let entry = RoomListEntry {
1027            room_id: "dev-chat".into(),
1028            visibility: RoomVisibility::Public,
1029            member_count: 5,
1030            created_by: "alice".into(),
1031        };
1032        let json = serde_json::to_string(&entry).unwrap();
1033        let back: RoomListEntry = serde_json::from_str(&json).unwrap();
1034        assert_eq!(back.room_id, "dev-chat");
1035        assert_eq!(back.visibility, RoomVisibility::Public);
1036        assert_eq!(back.member_count, 5);
1037    }
1038
1039    // ── parse_mentions tests ────────────────────────────────────────────────
1040
1041    #[test]
1042    fn parse_mentions_single() {
1043        assert_eq!(parse_mentions("hello @alice"), vec!["alice"]);
1044    }
1045
1046    #[test]
1047    fn parse_mentions_multiple() {
1048        assert_eq!(
1049            parse_mentions("@alice and @bob should see this"),
1050            vec!["alice", "bob"]
1051        );
1052    }
1053
1054    #[test]
1055    fn parse_mentions_at_start() {
1056        assert_eq!(parse_mentions("@alice hello"), vec!["alice"]);
1057    }
1058
1059    #[test]
1060    fn parse_mentions_at_end() {
1061        assert_eq!(parse_mentions("hello @alice"), vec!["alice"]);
1062    }
1063
1064    #[test]
1065    fn parse_mentions_with_hyphens_and_underscores() {
1066        assert_eq!(parse_mentions("cc @my-agent_2"), vec!["my-agent_2"]);
1067    }
1068
1069    #[test]
1070    fn parse_mentions_deduplicates() {
1071        assert_eq!(parse_mentions("@alice @bob @alice"), vec!["alice", "bob"]);
1072    }
1073
1074    #[test]
1075    fn parse_mentions_skips_email() {
1076        assert!(parse_mentions("send to user@example.com").is_empty());
1077    }
1078
1079    #[test]
1080    fn parse_mentions_skips_bare_at() {
1081        assert!(parse_mentions("@ alone").is_empty());
1082    }
1083
1084    #[test]
1085    fn parse_mentions_empty_content() {
1086        assert!(parse_mentions("").is_empty());
1087    }
1088
1089    #[test]
1090    fn parse_mentions_no_mentions() {
1091        assert!(parse_mentions("just a normal message").is_empty());
1092    }
1093
1094    #[test]
1095    fn parse_mentions_punctuation_after_username() {
1096        assert_eq!(parse_mentions("hey @alice, what's up?"), vec!["alice"]);
1097    }
1098
1099    #[test]
1100    fn parse_mentions_multiple_at_signs() {
1101        // user@@foo — second @ is preceded by non-whitespace, so skipped
1102        assert_eq!(parse_mentions("@alice@@bob"), vec!["alice"]);
1103    }
1104
1105    // ── content() and mentions() method tests ───────────────────────────────
1106
1107    #[test]
1108    fn message_content_returns_text() {
1109        let msg = make_message("r", "alice", "hello @bob");
1110        assert_eq!(msg.content(), Some("hello @bob"));
1111    }
1112
1113    #[test]
1114    fn join_content_returns_none() {
1115        let msg = make_join("r", "alice");
1116        assert!(msg.content().is_none());
1117    }
1118
1119    #[test]
1120    fn message_mentions_extracts_usernames() {
1121        let msg = make_message("r", "alice", "hey @bob and @carol");
1122        assert_eq!(msg.mentions(), vec!["bob", "carol"]);
1123    }
1124
1125    #[test]
1126    fn join_mentions_returns_empty() {
1127        let msg = make_join("r", "alice");
1128        assert!(msg.mentions().is_empty());
1129    }
1130
1131    #[test]
1132    fn dm_mentions_works() {
1133        let msg = make_dm("r", "alice", "bob", "cc @carol on this");
1134        assert_eq!(msg.mentions(), vec!["carol"]);
1135    }
1136
1137    #[test]
1138    fn reply_content_returns_text() {
1139        let msg = make_reply("r", "alice", "msg-1", "@bob noted");
1140        assert_eq!(msg.content(), Some("@bob noted"));
1141        assert_eq!(msg.mentions(), vec!["bob"]);
1142    }
1143
1144    // ── format_message_id / parse_message_id tests ───────────────────────────
1145
1146    #[test]
1147    fn format_message_id_basic() {
1148        assert_eq!(format_message_id("agent-room", 42), "agent-room:42");
1149    }
1150
1151    #[test]
1152    fn format_message_id_seq_zero() {
1153        assert_eq!(format_message_id("r", 0), "r:0");
1154    }
1155
1156    #[test]
1157    fn format_message_id_max_seq() {
1158        assert_eq!(format_message_id("r", u64::MAX), format!("r:{}", u64::MAX));
1159    }
1160
1161    #[test]
1162    fn parse_message_id_basic() {
1163        let (room, seq) = parse_message_id("agent-room:42").unwrap();
1164        assert_eq!(room, "agent-room");
1165        assert_eq!(seq, 42);
1166    }
1167
1168    #[test]
1169    fn parse_message_id_round_trips() {
1170        let id = format_message_id("dev-chat", 99);
1171        let (room, seq) = parse_message_id(&id).unwrap();
1172        assert_eq!(room, "dev-chat");
1173        assert_eq!(seq, 99);
1174    }
1175
1176    #[test]
1177    fn parse_message_id_room_with_colon() {
1178        // Room ID that itself contains a colon — split on last colon.
1179        let (room, seq) = parse_message_id("namespace:room:7").unwrap();
1180        assert_eq!(room, "namespace:room");
1181        assert_eq!(seq, 7);
1182    }
1183
1184    #[test]
1185    fn parse_message_id_no_colon_errors() {
1186        assert!(parse_message_id("nocolon").is_err());
1187    }
1188
1189    #[test]
1190    fn parse_message_id_invalid_seq_errors() {
1191        assert!(parse_message_id("room:notanumber").is_err());
1192    }
1193
1194    #[test]
1195    fn parse_message_id_negative_seq_errors() {
1196        // Negative numbers are not valid u64.
1197        assert!(parse_message_id("room:-1").is_err());
1198    }
1199
1200    #[test]
1201    fn parse_message_id_empty_room_ok() {
1202        // Edge case: empty room component.
1203        let (room, seq) = parse_message_id(":5").unwrap();
1204        assert_eq!(room, "");
1205        assert_eq!(seq, 5);
1206    }
1207
1208    // ── SubscriptionTier tests ───────────────────────────────────────────────
1209
1210    #[test]
1211    fn subscription_tier_serde_round_trip() {
1212        for tier in [
1213            SubscriptionTier::Full,
1214            SubscriptionTier::MentionsOnly,
1215            SubscriptionTier::Unsubscribed,
1216        ] {
1217            let json = serde_json::to_string(&tier).unwrap();
1218            let back: SubscriptionTier = serde_json::from_str(&json).unwrap();
1219            assert_eq!(tier, back);
1220        }
1221    }
1222
1223    #[test]
1224    fn subscription_tier_serde_snake_case() {
1225        assert_eq!(
1226            serde_json::to_string(&SubscriptionTier::Full).unwrap(),
1227            r#""full""#
1228        );
1229        assert_eq!(
1230            serde_json::to_string(&SubscriptionTier::MentionsOnly).unwrap(),
1231            r#""mentions_only""#
1232        );
1233        assert_eq!(
1234            serde_json::to_string(&SubscriptionTier::Unsubscribed).unwrap(),
1235            r#""unsubscribed""#
1236        );
1237    }
1238
1239    #[test]
1240    fn subscription_tier_display() {
1241        assert_eq!(SubscriptionTier::Full.to_string(), "full");
1242        assert_eq!(SubscriptionTier::MentionsOnly.to_string(), "mentions_only");
1243        assert_eq!(SubscriptionTier::Unsubscribed.to_string(), "unsubscribed");
1244    }
1245
1246    #[test]
1247    fn subscription_tier_from_str_canonical() {
1248        assert_eq!(
1249            "full".parse::<SubscriptionTier>().unwrap(),
1250            SubscriptionTier::Full
1251        );
1252        assert_eq!(
1253            "mentions_only".parse::<SubscriptionTier>().unwrap(),
1254            SubscriptionTier::MentionsOnly
1255        );
1256        assert_eq!(
1257            "unsubscribed".parse::<SubscriptionTier>().unwrap(),
1258            SubscriptionTier::Unsubscribed
1259        );
1260    }
1261
1262    #[test]
1263    fn subscription_tier_from_str_aliases() {
1264        assert_eq!(
1265            "mentions-only".parse::<SubscriptionTier>().unwrap(),
1266            SubscriptionTier::MentionsOnly
1267        );
1268        assert_eq!(
1269            "mentions".parse::<SubscriptionTier>().unwrap(),
1270            SubscriptionTier::MentionsOnly
1271        );
1272        assert_eq!(
1273            "none".parse::<SubscriptionTier>().unwrap(),
1274            SubscriptionTier::Unsubscribed
1275        );
1276    }
1277
1278    #[test]
1279    fn subscription_tier_from_str_invalid() {
1280        let err = "banana".parse::<SubscriptionTier>().unwrap_err();
1281        assert!(err.contains("unknown subscription tier"));
1282        assert!(err.contains("banana"));
1283    }
1284
1285    #[test]
1286    fn subscription_tier_display_round_trips_through_from_str() {
1287        for tier in [
1288            SubscriptionTier::Full,
1289            SubscriptionTier::MentionsOnly,
1290            SubscriptionTier::Unsubscribed,
1291        ] {
1292            let s = tier.to_string();
1293            let back: SubscriptionTier = s.parse().unwrap();
1294            assert_eq!(tier, back);
1295        }
1296    }
1297
1298    #[test]
1299    fn subscription_tier_is_copy() {
1300        let tier = SubscriptionTier::Full;
1301        let copy = tier;
1302        assert_eq!(tier, copy); // both valid — proves Copy
1303    }
1304}