Skip to main content

room_protocol/
lib.rs

1pub mod plugin;
2
3use std::collections::{BTreeSet, HashSet};
4use std::fmt;
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10/// Error returned when constructing a DM room ID with invalid inputs.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum DmRoomError {
13    /// Both usernames are the same — a DM requires two distinct users.
14    SameUser(String),
15}
16
17impl fmt::Display for DmRoomError {
18    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19        match self {
20            DmRoomError::SameUser(user) => {
21                write!(f, "cannot create DM room: both users are '{user}'")
22            }
23        }
24    }
25}
26
27impl std::error::Error for DmRoomError {}
28
29/// Visibility level for a room, controlling who can discover and join it.
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum RoomVisibility {
33    /// Anyone can discover and join.
34    Public,
35    /// Discoverable in listings but requires invite to join.
36    Private,
37    /// Not discoverable; join requires knowing room ID + invite.
38    Unlisted,
39    /// Private 2-person room, auto-created by `/dm` command.
40    Dm,
41}
42
43/// Configuration for a room's access controls and metadata.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct RoomConfig {
46    pub visibility: RoomVisibility,
47    /// Maximum number of members. `None` = unlimited.
48    pub max_members: Option<usize>,
49    /// Usernames allowed to join (for private/unlisted/dm rooms).
50    pub invite_list: HashSet<String>,
51    /// Username of the room creator.
52    pub created_by: String,
53    /// ISO 8601 creation timestamp.
54    pub created_at: String,
55}
56
57impl RoomConfig {
58    /// Create a default public room config.
59    pub fn public(created_by: &str) -> Self {
60        Self {
61            visibility: RoomVisibility::Public,
62            max_members: None,
63            invite_list: HashSet::new(),
64            created_by: created_by.to_owned(),
65            created_at: Utc::now().to_rfc3339(),
66        }
67    }
68
69    /// Create a DM room config for two users.
70    pub fn dm(user_a: &str, user_b: &str) -> Self {
71        let mut invite_list = HashSet::new();
72        invite_list.insert(user_a.to_owned());
73        invite_list.insert(user_b.to_owned());
74        Self {
75            visibility: RoomVisibility::Dm,
76            max_members: Some(2),
77            invite_list,
78            created_by: user_a.to_owned(),
79            created_at: Utc::now().to_rfc3339(),
80        }
81    }
82}
83
84/// Compute the deterministic room ID for a DM between two users.
85///
86/// Sorts usernames alphabetically so `/dm alice` from bob and `/dm bob` from
87/// alice both resolve to the same room.
88///
89/// # Errors
90///
91/// Returns [`DmRoomError::SameUser`] if both usernames are identical.
92pub fn dm_room_id(user_a: &str, user_b: &str) -> Result<String, DmRoomError> {
93    if user_a == user_b {
94        return Err(DmRoomError::SameUser(user_a.to_owned()));
95    }
96    let (first, second) = if user_a < user_b {
97        (user_a, user_b)
98    } else {
99        (user_b, user_a)
100    };
101    Ok(format!("dm-{first}-{second}"))
102}
103
104/// Check whether a room ID represents a DM room.
105///
106/// DM room IDs follow the pattern `dm-<user_a>-<user_b>` where usernames are
107/// sorted alphabetically.
108pub fn is_dm_room(room_id: &str) -> bool {
109    room_id.starts_with("dm-") && room_id.matches('-').count() >= 2
110}
111
112/// Subscription tier for a user's relationship with a room.
113///
114/// Controls what messages appear in the user's default stream:
115/// - `Full` — all messages from the room
116/// - `MentionsOnly` — only messages that @mention the user
117/// - `Unsubscribed` — excluded from the default stream (still queryable with `--public`)
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum SubscriptionTier {
121    Full,
122    MentionsOnly,
123    Unsubscribed,
124}
125
126impl std::fmt::Display for SubscriptionTier {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        match self {
129            Self::Full => write!(f, "full"),
130            Self::MentionsOnly => write!(f, "mentions_only"),
131            Self::Unsubscribed => write!(f, "unsubscribed"),
132        }
133    }
134}
135
136impl std::str::FromStr for SubscriptionTier {
137    type Err = String;
138
139    fn from_str(s: &str) -> Result<Self, Self::Err> {
140        match s {
141            "full" => Ok(Self::Full),
142            "mentions_only" | "mentions-only" | "mentions" => Ok(Self::MentionsOnly),
143            "unsubscribed" | "none" => Ok(Self::Unsubscribed),
144            other => Err(format!(
145                "unknown subscription tier '{other}'; expected full, mentions_only, or unsubscribed"
146            )),
147        }
148    }
149}
150
151/// Typed event categories for structured event filtering.
152///
153/// Used with the [`Message::Event`] variant. Marked `#[non_exhaustive]` so new
154/// event types can be added without a breaking change.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
156#[serde(rename_all = "snake_case")]
157#[non_exhaustive]
158pub enum EventType {
159    TaskPosted,
160    TaskAssigned,
161    TaskClaimed,
162    TaskPlanned,
163    TaskApproved,
164    TaskUpdated,
165    TaskReleased,
166    TaskFinished,
167    TaskCancelled,
168    StatusChanged,
169    ReviewRequested,
170    ReviewClaimed,
171    TaskRejected,
172}
173
174impl fmt::Display for EventType {
175    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176        match self {
177            Self::TaskPosted => write!(f, "task_posted"),
178            Self::TaskAssigned => write!(f, "task_assigned"),
179            Self::TaskClaimed => write!(f, "task_claimed"),
180            Self::TaskPlanned => write!(f, "task_planned"),
181            Self::TaskApproved => write!(f, "task_approved"),
182            Self::TaskUpdated => write!(f, "task_updated"),
183            Self::TaskReleased => write!(f, "task_released"),
184            Self::TaskFinished => write!(f, "task_finished"),
185            Self::TaskCancelled => write!(f, "task_cancelled"),
186            Self::StatusChanged => write!(f, "status_changed"),
187            Self::ReviewRequested => write!(f, "review_requested"),
188            Self::ReviewClaimed => write!(f, "review_claimed"),
189            Self::TaskRejected => write!(f, "task_rejected"),
190        }
191    }
192}
193
194impl std::str::FromStr for EventType {
195    type Err = String;
196
197    fn from_str(s: &str) -> Result<Self, Self::Err> {
198        match s {
199            "task_posted" => Ok(Self::TaskPosted),
200            "task_assigned" => Ok(Self::TaskAssigned),
201            "task_claimed" => Ok(Self::TaskClaimed),
202            "task_planned" => Ok(Self::TaskPlanned),
203            "task_approved" => Ok(Self::TaskApproved),
204            "task_updated" => Ok(Self::TaskUpdated),
205            "task_released" => Ok(Self::TaskReleased),
206            "task_finished" => Ok(Self::TaskFinished),
207            "task_cancelled" => Ok(Self::TaskCancelled),
208            "status_changed" => Ok(Self::StatusChanged),
209            "review_requested" => Ok(Self::ReviewRequested),
210            "review_claimed" => Ok(Self::ReviewClaimed),
211            "task_rejected" => Ok(Self::TaskRejected),
212            other => Err(format!(
213                "unknown event type '{other}'; expected one of: task_posted, task_assigned, \
214                 task_claimed, task_planned, task_approved, task_updated, task_released, \
215                 task_finished, task_cancelled, status_changed, review_requested, \
216                 review_claimed, task_rejected"
217            )),
218        }
219    }
220}
221
222impl Ord for EventType {
223    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
224        self.to_string().cmp(&other.to_string())
225    }
226}
227
228impl PartialOrd for EventType {
229    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
230        Some(self.cmp(other))
231    }
232}
233
234/// Filter controlling which event types a user receives during poll.
235///
236/// Used alongside [`SubscriptionTier`] to give fine-grained control over the
237/// event stream. Tier controls message-level filtering (all, mentions, none);
238/// `EventFilter` controls which [`EventType`] values pass through for
239/// [`Message::Event`] messages specifically.
240///
241/// Non-Event messages are never affected by the event filter.
242#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(rename_all = "snake_case", tag = "filter")]
244pub enum EventFilter {
245    /// Receive all event types (the default).
246    #[default]
247    All,
248    /// Receive no events.
249    None,
250    /// Receive only the listed event types.
251    Only {
252        #[serde(default)]
253        types: BTreeSet<EventType>,
254    },
255}
256
257impl fmt::Display for EventFilter {
258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259        match self {
260            Self::All => write!(f, "all"),
261            Self::None => write!(f, "none"),
262            Self::Only { types } => {
263                let names: Vec<String> = types.iter().map(|t| t.to_string()).collect();
264                write!(f, "{}", names.join(","))
265            }
266        }
267    }
268}
269
270impl std::str::FromStr for EventFilter {
271    type Err = String;
272
273    /// Parse an event filter from a string.
274    ///
275    /// Accepted formats:
276    /// - `"all"` → [`EventFilter::All`]
277    /// - `"none"` → [`EventFilter::None`]
278    /// - `"task_posted,task_finished"` → [`EventFilter::Only`] with those types
279    fn from_str(s: &str) -> Result<Self, Self::Err> {
280        match s {
281            "all" => Ok(Self::All),
282            "none" => Ok(Self::None),
283            "" => Ok(Self::All),
284            csv => {
285                let mut types = BTreeSet::new();
286                for part in csv.split(',') {
287                    let trimmed = part.trim();
288                    if trimmed.is_empty() {
289                        continue;
290                    }
291                    let et: EventType = trimmed.parse()?;
292                    types.insert(et);
293                }
294                if types.is_empty() {
295                    Ok(Self::All)
296                } else {
297                    Ok(Self::Only { types })
298                }
299            }
300        }
301    }
302}
303
304impl EventFilter {
305    /// Check whether a given event type passes this filter.
306    pub fn allows(&self, event_type: &EventType) -> bool {
307        match self {
308            Self::All => true,
309            Self::None => false,
310            Self::Only { types } => types.contains(event_type),
311        }
312    }
313}
314
315/// Entry returned by room listing (discovery).
316#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct RoomListEntry {
318    pub room_id: String,
319    pub visibility: RoomVisibility,
320    pub member_count: usize,
321    pub created_by: String,
322}
323
324/// Wire format for all messages stored in the chat file and sent over the socket.
325///
326/// Uses `#[serde(tag = "type")]` internally-tagged enum **without** `#[serde(flatten)]`
327/// to avoid the serde flatten + internally-tagged footgun that breaks deserialization.
328/// Every variant carries its own id/room/user/ts fields.
329#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
330#[serde(tag = "type", rename_all = "snake_case")]
331pub enum Message {
332    Join {
333        id: String,
334        room: String,
335        user: String,
336        ts: DateTime<Utc>,
337        #[serde(default, skip_serializing_if = "Option::is_none")]
338        seq: Option<u64>,
339    },
340    Leave {
341        id: String,
342        room: String,
343        user: String,
344        ts: DateTime<Utc>,
345        #[serde(default, skip_serializing_if = "Option::is_none")]
346        seq: Option<u64>,
347    },
348    Message {
349        id: String,
350        room: String,
351        user: String,
352        ts: DateTime<Utc>,
353        #[serde(default, skip_serializing_if = "Option::is_none")]
354        seq: Option<u64>,
355        content: String,
356    },
357    Reply {
358        id: String,
359        room: String,
360        user: String,
361        ts: DateTime<Utc>,
362        #[serde(default, skip_serializing_if = "Option::is_none")]
363        seq: Option<u64>,
364        reply_to: String,
365        content: String,
366    },
367    Command {
368        id: String,
369        room: String,
370        user: String,
371        ts: DateTime<Utc>,
372        #[serde(default, skip_serializing_if = "Option::is_none")]
373        seq: Option<u64>,
374        cmd: String,
375        params: Vec<String>,
376    },
377    System {
378        id: String,
379        room: String,
380        user: String,
381        ts: DateTime<Utc>,
382        #[serde(default, skip_serializing_if = "Option::is_none")]
383        seq: Option<u64>,
384        content: String,
385        /// Optional machine-readable data for programmatic consumers.
386        #[serde(default, skip_serializing_if = "Option::is_none")]
387        data: Option<serde_json::Value>,
388    },
389    /// A private direct message. Delivered only to sender, recipient, and the
390    /// broker host. Always written to the chat history file.
391    #[serde(rename = "dm")]
392    DirectMessage {
393        id: String,
394        room: String,
395        /// Sender username (set by the broker).
396        user: String,
397        ts: DateTime<Utc>,
398        #[serde(default, skip_serializing_if = "Option::is_none")]
399        seq: Option<u64>,
400        /// Recipient username.
401        to: String,
402        content: String,
403    },
404    /// A typed event for structured filtering. Carries an [`EventType`] alongside
405    /// human-readable content and optional machine-readable params.
406    Event {
407        id: String,
408        room: String,
409        user: String,
410        ts: DateTime<Utc>,
411        #[serde(default, skip_serializing_if = "Option::is_none")]
412        seq: Option<u64>,
413        event_type: EventType,
414        content: String,
415        #[serde(default, skip_serializing_if = "Option::is_none")]
416        params: Option<serde_json::Value>,
417    },
418}
419
420impl Message {
421    pub fn id(&self) -> &str {
422        match self {
423            Self::Join { id, .. }
424            | Self::Leave { id, .. }
425            | Self::Message { id, .. }
426            | Self::Reply { id, .. }
427            | Self::Command { id, .. }
428            | Self::System { id, .. }
429            | Self::DirectMessage { id, .. }
430            | Self::Event { id, .. } => id,
431        }
432    }
433
434    pub fn room(&self) -> &str {
435        match self {
436            Self::Join { room, .. }
437            | Self::Leave { room, .. }
438            | Self::Message { room, .. }
439            | Self::Reply { room, .. }
440            | Self::Command { room, .. }
441            | Self::System { room, .. }
442            | Self::DirectMessage { room, .. }
443            | Self::Event { room, .. } => room,
444        }
445    }
446
447    pub fn user(&self) -> &str {
448        match self {
449            Self::Join { user, .. }
450            | Self::Leave { user, .. }
451            | Self::Message { user, .. }
452            | Self::Reply { user, .. }
453            | Self::Command { user, .. }
454            | Self::System { user, .. }
455            | Self::DirectMessage { user, .. }
456            | Self::Event { user, .. } => user,
457        }
458    }
459
460    pub fn ts(&self) -> &DateTime<Utc> {
461        match self {
462            Self::Join { ts, .. }
463            | Self::Leave { ts, .. }
464            | Self::Message { ts, .. }
465            | Self::Reply { ts, .. }
466            | Self::Command { ts, .. }
467            | Self::System { ts, .. }
468            | Self::DirectMessage { ts, .. }
469            | Self::Event { ts, .. } => ts,
470        }
471    }
472
473    /// Returns the sequence number assigned by the broker, or `None` for
474    /// messages loaded from history files that predate this feature.
475    pub fn seq(&self) -> Option<u64> {
476        match self {
477            Self::Join { seq, .. }
478            | Self::Leave { seq, .. }
479            | Self::Message { seq, .. }
480            | Self::Reply { seq, .. }
481            | Self::Command { seq, .. }
482            | Self::System { seq, .. }
483            | Self::DirectMessage { seq, .. }
484            | Self::Event { seq, .. } => *seq,
485        }
486    }
487
488    /// Returns the text content of this message, or `None` for variants without content
489    /// (Join, Leave, Command).
490    pub fn content(&self) -> Option<&str> {
491        match self {
492            Self::Message { content, .. }
493            | Self::Reply { content, .. }
494            | Self::System { content, .. }
495            | Self::DirectMessage { content, .. }
496            | Self::Event { content, .. } => Some(content),
497            Self::Join { .. } | Self::Leave { .. } | Self::Command { .. } => None,
498        }
499    }
500
501    /// Extract @mentions from this message's content.
502    ///
503    /// Returns an empty vec for variants without content (Join, Leave, Command)
504    /// or content with no @mentions.
505    pub fn mentions(&self) -> Vec<String> {
506        match self.content() {
507            Some(content) => parse_mentions(content),
508            None => Vec::new(),
509        }
510    }
511
512    /// Returns `true` if `viewer` is allowed to see this message.
513    ///
514    /// All non-DM variants are visible to everyone. A [`Message::DirectMessage`]
515    /// is visible only to the sender (`user`), the recipient (`to`), and the
516    /// room host (when `host == Some(viewer)`).
517    pub fn is_visible_to(&self, viewer: &str, host: Option<&str>) -> bool {
518        match self {
519            Self::DirectMessage { user, to, .. } => {
520                viewer == user || viewer == to.as_str() || host == Some(viewer)
521            }
522            _ => true,
523        }
524    }
525
526    /// Assign a broker-issued sequence number to this message.
527    pub fn set_seq(&mut self, seq: u64) {
528        let n = Some(seq);
529        match self {
530            Self::Join { seq, .. } => *seq = n,
531            Self::Leave { seq, .. } => *seq = n,
532            Self::Message { seq, .. } => *seq = n,
533            Self::Reply { seq, .. } => *seq = n,
534            Self::Command { seq, .. } => *seq = n,
535            Self::System { seq, .. } => *seq = n,
536            Self::DirectMessage { seq, .. } => *seq = n,
537            Self::Event { seq, .. } => *seq = n,
538        }
539    }
540}
541
542// ── Constructors ─────────────────────────────────────────────────────────────
543
544fn new_id() -> String {
545    Uuid::new_v4().to_string()
546}
547
548pub fn make_join(room: &str, user: &str) -> Message {
549    Message::Join {
550        id: new_id(),
551        room: room.to_owned(),
552        user: user.to_owned(),
553        ts: Utc::now(),
554        seq: None,
555    }
556}
557
558pub fn make_leave(room: &str, user: &str) -> Message {
559    Message::Leave {
560        id: new_id(),
561        room: room.to_owned(),
562        user: user.to_owned(),
563        ts: Utc::now(),
564        seq: None,
565    }
566}
567
568pub fn make_message(room: &str, user: &str, content: impl Into<String>) -> Message {
569    Message::Message {
570        id: new_id(),
571        room: room.to_owned(),
572        user: user.to_owned(),
573        ts: Utc::now(),
574        content: content.into(),
575        seq: None,
576    }
577}
578
579pub fn make_reply(
580    room: &str,
581    user: &str,
582    reply_to: impl Into<String>,
583    content: impl Into<String>,
584) -> Message {
585    Message::Reply {
586        id: new_id(),
587        room: room.to_owned(),
588        user: user.to_owned(),
589        ts: Utc::now(),
590        reply_to: reply_to.into(),
591        content: content.into(),
592        seq: None,
593    }
594}
595
596pub fn make_command(
597    room: &str,
598    user: &str,
599    cmd: impl Into<String>,
600    params: Vec<String>,
601) -> Message {
602    Message::Command {
603        id: new_id(),
604        room: room.to_owned(),
605        user: user.to_owned(),
606        ts: Utc::now(),
607        cmd: cmd.into(),
608        params,
609        seq: None,
610    }
611}
612
613pub fn make_system(room: &str, user: &str, content: impl Into<String>) -> Message {
614    Message::System {
615        id: new_id(),
616        room: room.to_owned(),
617        user: user.to_owned(),
618        ts: Utc::now(),
619        content: content.into(),
620        seq: None,
621        data: None,
622    }
623}
624
625pub fn make_system_with_data(
626    room: &str,
627    user: &str,
628    content: impl Into<String>,
629    data: serde_json::Value,
630) -> Message {
631    Message::System {
632        id: new_id(),
633        room: room.to_owned(),
634        user: user.to_owned(),
635        ts: Utc::now(),
636        content: content.into(),
637        seq: None,
638        data: Some(data),
639    }
640}
641
642pub fn make_dm(room: &str, user: &str, to: &str, content: impl Into<String>) -> Message {
643    Message::DirectMessage {
644        id: new_id(),
645        room: room.to_owned(),
646        user: user.to_owned(),
647        ts: Utc::now(),
648        to: to.to_owned(),
649        content: content.into(),
650        seq: None,
651    }
652}
653
654pub fn make_event(
655    room: &str,
656    user: &str,
657    event_type: EventType,
658    content: impl Into<String>,
659    params: Option<serde_json::Value>,
660) -> Message {
661    Message::Event {
662        id: new_id(),
663        room: room.to_owned(),
664        user: user.to_owned(),
665        ts: Utc::now(),
666        event_type,
667        content: content.into(),
668        params,
669        seq: None,
670    }
671}
672
673/// Extract @mentions from message content.
674///
675/// Matches `@username` patterns where usernames can contain alphanumerics, hyphens,
676/// and underscores. Stops at whitespace, punctuation (except `-` and `_`), or end of
677/// string. Skips email-like patterns (`user@domain`) by requiring the `@` to be at
678/// the start of the string or preceded by whitespace.
679///
680/// Returns a deduplicated list of mentioned usernames (without the `@` prefix),
681/// preserving first-occurrence order.
682pub fn parse_mentions(content: &str) -> Vec<String> {
683    let mut mentions = Vec::new();
684    let mut seen = HashSet::new();
685
686    for (i, _) in content.match_indices('@') {
687        // Skip if preceded by a non-whitespace char (email-like pattern)
688        if i > 0 {
689            let prev = content.as_bytes()[i - 1];
690            if !prev.is_ascii_whitespace() {
691                continue;
692            }
693        }
694
695        // Extract username chars after @
696        let rest = &content[i + 1..];
697        let end = rest
698            .find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
699            .unwrap_or(rest.len());
700        let username = &rest[..end];
701
702        if !username.is_empty() && seen.insert(username.to_owned()) {
703            mentions.push(username.to_owned());
704        }
705    }
706
707    mentions
708}
709
710/// Format a human-readable message ID from a room ID and sequence number.
711///
712/// The canonical format is `"<room>:<seq>"`, e.g. `"agent-room:42"`. This is a
713/// display-only identifier used by `--from`, `--to`, and `--id` flags. The wire
714/// format keeps `room` and `seq` as separate fields and never stores this string.
715pub fn format_message_id(room: &str, seq: u64) -> String {
716    format!("{room}:{seq}")
717}
718
719/// Parse a human-readable message ID back into `(room_id, seq)`.
720///
721/// Expects the format `"<room>:<seq>"` produced by [`format_message_id`].
722/// Splits on the **last** colon so room IDs that themselves contain colons are
723/// handled correctly (e.g. `"namespace:room:42"` → `("namespace:room", 42)`).
724///
725/// Returns `Err(String)` if the input has no colon or if the part after the
726/// last colon cannot be parsed as a `u64`.
727pub fn parse_message_id(id: &str) -> Result<(String, u64), String> {
728    let colon = id
729        .rfind(':')
730        .ok_or_else(|| format!("no colon in message ID: {id:?}"))?;
731    let room = &id[..colon];
732    let seq_str = &id[colon + 1..];
733    let seq = seq_str
734        .parse::<u64>()
735        .map_err(|_| format!("invalid sequence number in message ID: {id:?}"))?;
736    Ok((room.to_owned(), seq))
737}
738
739/// Parse a raw line from a client socket.
740/// JSON envelope → Message with broker-assigned id/room/ts.
741/// Plain text → Message::Message with broker-assigned metadata.
742pub fn parse_client_line(raw: &str, room: &str, user: &str) -> Result<Message, serde_json::Error> {
743    #[derive(Deserialize)]
744    #[serde(tag = "type", rename_all = "snake_case")]
745    enum Envelope {
746        Message {
747            content: String,
748        },
749        Reply {
750            reply_to: String,
751            content: String,
752        },
753        Command {
754            cmd: String,
755            params: Vec<String>,
756        },
757        #[serde(rename = "dm")]
758        Dm {
759            to: String,
760            content: String,
761        },
762    }
763
764    if raw.starts_with('{') {
765        let env: Envelope = serde_json::from_str(raw)?;
766        let msg = match env {
767            Envelope::Message { content } => make_message(room, user, content),
768            Envelope::Reply { reply_to, content } => make_reply(room, user, reply_to, content),
769            Envelope::Command { cmd, params } => make_command(room, user, cmd, params),
770            Envelope::Dm { to, content } => make_dm(room, user, &to, content),
771        };
772        Ok(msg)
773    } else {
774        Ok(make_message(room, user, raw))
775    }
776}
777
778// ── Tests ─────────────────────────────────────────────────────────────────────
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783
784    fn fixed_ts() -> DateTime<Utc> {
785        use chrono::TimeZone;
786        Utc.with_ymd_and_hms(2026, 3, 5, 10, 0, 0).unwrap()
787    }
788
789    fn fixed_id() -> String {
790        "00000000-0000-0000-0000-000000000001".to_owned()
791    }
792
793    // ── Round-trip tests ─────────────────────────────────────────────────────
794
795    #[test]
796    fn join_round_trips() {
797        let msg = Message::Join {
798            id: fixed_id(),
799            room: "r".into(),
800            user: "alice".into(),
801            ts: fixed_ts(),
802            seq: None,
803        };
804        let json = serde_json::to_string(&msg).unwrap();
805        let back: Message = serde_json::from_str(&json).unwrap();
806        assert_eq!(msg, back);
807    }
808
809    #[test]
810    fn leave_round_trips() {
811        let msg = Message::Leave {
812            id: fixed_id(),
813            room: "r".into(),
814            user: "bob".into(),
815            ts: fixed_ts(),
816            seq: None,
817        };
818        let json = serde_json::to_string(&msg).unwrap();
819        let back: Message = serde_json::from_str(&json).unwrap();
820        assert_eq!(msg, back);
821    }
822
823    #[test]
824    fn message_round_trips() {
825        let msg = Message::Message {
826            id: fixed_id(),
827            room: "r".into(),
828            user: "alice".into(),
829            ts: fixed_ts(),
830            content: "hello world".into(),
831            seq: None,
832        };
833        let json = serde_json::to_string(&msg).unwrap();
834        let back: Message = serde_json::from_str(&json).unwrap();
835        assert_eq!(msg, back);
836    }
837
838    #[test]
839    fn reply_round_trips() {
840        let msg = Message::Reply {
841            id: fixed_id(),
842            room: "r".into(),
843            user: "bob".into(),
844            ts: fixed_ts(),
845            reply_to: "ffffffff-0000-0000-0000-000000000000".into(),
846            content: "pong".into(),
847            seq: None,
848        };
849        let json = serde_json::to_string(&msg).unwrap();
850        let back: Message = serde_json::from_str(&json).unwrap();
851        assert_eq!(msg, back);
852    }
853
854    #[test]
855    fn command_round_trips() {
856        let msg = Message::Command {
857            id: fixed_id(),
858            room: "r".into(),
859            user: "alice".into(),
860            ts: fixed_ts(),
861            cmd: "claim".into(),
862            params: vec!["task-123".into(), "fix the bug".into()],
863            seq: None,
864        };
865        let json = serde_json::to_string(&msg).unwrap();
866        let back: Message = serde_json::from_str(&json).unwrap();
867        assert_eq!(msg, back);
868    }
869
870    #[test]
871    fn system_round_trips() {
872        let msg = Message::System {
873            id: fixed_id(),
874            room: "r".into(),
875            user: "broker".into(),
876            ts: fixed_ts(),
877            content: "5 users online".into(),
878            seq: None,
879            data: None,
880        };
881        let json = serde_json::to_string(&msg).unwrap();
882        let back: Message = serde_json::from_str(&json).unwrap();
883        assert_eq!(msg, back);
884    }
885
886    #[test]
887    fn system_with_data_round_trips() {
888        let data = serde_json::json!({
889            "action": "spawn",
890            "username": "bot1",
891            "pid": 12345,
892        });
893        let msg = Message::System {
894            id: fixed_id(),
895            room: "r".into(),
896            user: "plugin:agent".into(),
897            ts: fixed_ts(),
898            content: "agent bot1 spawned".into(),
899            seq: None,
900            data: Some(data),
901        };
902        let json = serde_json::to_string(&msg).unwrap();
903        let back: Message = serde_json::from_str(&json).unwrap();
904        assert_eq!(msg, back);
905        // Verify data field is present in JSON
906        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
907        assert_eq!(v["data"]["action"], "spawn");
908        assert_eq!(v["data"]["pid"], 12345);
909    }
910
911    #[test]
912    fn system_without_data_omits_field() {
913        let msg = Message::System {
914            id: fixed_id(),
915            room: "r".into(),
916            user: "broker".into(),
917            ts: fixed_ts(),
918            content: "hello".into(),
919            seq: None,
920            data: None,
921        };
922        let json = serde_json::to_string(&msg).unwrap();
923        // data field should not appear when None
924        assert!(!json.contains("\"data\""));
925        // Should still deserialize fine
926        let back: Message = serde_json::from_str(&json).unwrap();
927        assert_eq!(msg, back);
928    }
929
930    #[test]
931    fn system_data_backward_compat_no_data_field() {
932        // Old messages without a data field should deserialize with data: None
933        let json = r#"{"type":"system","id":"00000000-0000-0000-0000-000000000001","room":"r","user":"broker","ts":"2025-01-01T00:00:00Z","content":"hello"}"#;
934        let msg: Message = serde_json::from_str(json).unwrap();
935        if let Message::System { data, .. } = &msg {
936            assert!(
937                data.is_none(),
938                "missing data field should deserialize as None"
939            );
940        } else {
941            panic!("expected System");
942        }
943    }
944
945    // ── JSON shape tests ─────────────────────────────────────────────────────
946
947    #[test]
948    fn join_json_has_type_field_at_top_level() {
949        let msg = Message::Join {
950            id: fixed_id(),
951            room: "r".into(),
952            user: "alice".into(),
953            ts: fixed_ts(),
954            seq: None,
955        };
956        let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
957        assert_eq!(v["type"], "join");
958        assert_eq!(v["user"], "alice");
959        assert_eq!(v["room"], "r");
960        assert!(
961            v.get("content").is_none(),
962            "join should not have content field"
963        );
964    }
965
966    #[test]
967    fn message_json_has_content_at_top_level() {
968        let msg = Message::Message {
969            id: fixed_id(),
970            room: "r".into(),
971            user: "alice".into(),
972            ts: fixed_ts(),
973            content: "hi".into(),
974            seq: None,
975        };
976        let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
977        assert_eq!(v["type"], "message");
978        assert_eq!(v["content"], "hi");
979    }
980
981    #[test]
982    fn deserialize_join_from_literal() {
983        let raw = r#"{"type":"join","id":"abc","room":"myroom","user":"alice","ts":"2026-03-05T10:00:00Z"}"#;
984        let msg: Message = serde_json::from_str(raw).unwrap();
985        assert!(matches!(msg, Message::Join { .. }));
986        assert_eq!(msg.user(), "alice");
987    }
988
989    #[test]
990    fn deserialize_message_from_literal() {
991        let raw = r#"{"type":"message","id":"abc","room":"r","user":"bob","ts":"2026-03-05T10:00:00Z","content":"yo"}"#;
992        let msg: Message = serde_json::from_str(raw).unwrap();
993        assert!(matches!(&msg, Message::Message { content, .. } if content == "yo"));
994    }
995
996    #[test]
997    fn deserialize_command_with_empty_params() {
998        let raw = r#"{"type":"command","id":"x","room":"r","user":"u","ts":"2026-03-05T10:00:00Z","cmd":"status","params":[]}"#;
999        let msg: Message = serde_json::from_str(raw).unwrap();
1000        assert!(
1001            matches!(&msg, Message::Command { cmd, params, .. } if cmd == "status" && params.is_empty())
1002        );
1003    }
1004
1005    // ── parse_client_line tests ───────────────────────────────────────────────
1006
1007    #[test]
1008    fn parse_plain_text_becomes_message() {
1009        let msg = parse_client_line("hello there", "myroom", "alice").unwrap();
1010        assert!(matches!(&msg, Message::Message { content, .. } if content == "hello there"));
1011        assert_eq!(msg.user(), "alice");
1012        assert_eq!(msg.room(), "myroom");
1013    }
1014
1015    #[test]
1016    fn parse_json_message_envelope() {
1017        let raw = r#"{"type":"message","content":"from agent"}"#;
1018        let msg = parse_client_line(raw, "r", "bot1").unwrap();
1019        assert!(matches!(&msg, Message::Message { content, .. } if content == "from agent"));
1020    }
1021
1022    #[test]
1023    fn parse_json_reply_envelope() {
1024        let raw = r#"{"type":"reply","reply_to":"deadbeef","content":"ack"}"#;
1025        let msg = parse_client_line(raw, "r", "bot1").unwrap();
1026        assert!(
1027            matches!(&msg, Message::Reply { reply_to, content, .. } if reply_to == "deadbeef" && content == "ack")
1028        );
1029    }
1030
1031    #[test]
1032    fn parse_json_command_envelope() {
1033        let raw = r#"{"type":"command","cmd":"claim","params":["task-42"]}"#;
1034        let msg = parse_client_line(raw, "r", "agent").unwrap();
1035        assert!(
1036            matches!(&msg, Message::Command { cmd, params, .. } if cmd == "claim" && params == &["task-42"])
1037        );
1038    }
1039
1040    #[test]
1041    fn parse_invalid_json_errors() {
1042        let result = parse_client_line(r#"{"type":"unknown_type"}"#, "r", "u");
1043        assert!(result.is_err());
1044    }
1045
1046    #[test]
1047    fn parse_dm_envelope() {
1048        let raw = r#"{"type":"dm","to":"bob","content":"hey bob"}"#;
1049        let msg = parse_client_line(raw, "r", "alice").unwrap();
1050        assert!(
1051            matches!(&msg, Message::DirectMessage { to, content, .. } if to == "bob" && content == "hey bob")
1052        );
1053        assert_eq!(msg.user(), "alice");
1054    }
1055
1056    #[test]
1057    fn dm_round_trips() {
1058        let msg = Message::DirectMessage {
1059            id: fixed_id(),
1060            room: "r".into(),
1061            user: "alice".into(),
1062            ts: fixed_ts(),
1063            to: "bob".into(),
1064            content: "secret".into(),
1065            seq: None,
1066        };
1067        let json = serde_json::to_string(&msg).unwrap();
1068        let back: Message = serde_json::from_str(&json).unwrap();
1069        assert_eq!(msg, back);
1070    }
1071
1072    #[test]
1073    fn dm_json_has_type_dm() {
1074        let msg = Message::DirectMessage {
1075            id: fixed_id(),
1076            room: "r".into(),
1077            user: "alice".into(),
1078            ts: fixed_ts(),
1079            to: "bob".into(),
1080            content: "hi".into(),
1081            seq: None,
1082        };
1083        let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
1084        assert_eq!(v["type"], "dm");
1085        assert_eq!(v["to"], "bob");
1086        assert_eq!(v["content"], "hi");
1087    }
1088
1089    // ── is_visible_to tests ───────────────────────────────────────────────────
1090
1091    fn make_test_dm(from: &str, to: &str) -> Message {
1092        Message::DirectMessage {
1093            id: fixed_id(),
1094            room: "r".into(),
1095            user: from.into(),
1096            ts: fixed_ts(),
1097            seq: None,
1098            to: to.into(),
1099            content: "secret".into(),
1100        }
1101    }
1102
1103    #[test]
1104    fn dm_visible_to_sender() {
1105        let msg = make_test_dm("alice", "bob");
1106        assert!(msg.is_visible_to("alice", None));
1107    }
1108
1109    #[test]
1110    fn dm_visible_to_recipient() {
1111        let msg = make_test_dm("alice", "bob");
1112        assert!(msg.is_visible_to("bob", None));
1113    }
1114
1115    #[test]
1116    fn dm_visible_to_host() {
1117        let msg = make_test_dm("alice", "bob");
1118        assert!(msg.is_visible_to("carol", Some("carol")));
1119    }
1120
1121    #[test]
1122    fn dm_hidden_from_non_participant() {
1123        let msg = make_test_dm("alice", "bob");
1124        assert!(!msg.is_visible_to("carol", None));
1125    }
1126
1127    #[test]
1128    fn dm_non_participant_not_elevated_by_different_host() {
1129        let msg = make_test_dm("alice", "bob");
1130        assert!(!msg.is_visible_to("carol", Some("dave")));
1131    }
1132
1133    #[test]
1134    fn non_dm_always_visible() {
1135        let msg = make_message("r", "alice", "hello");
1136        assert!(msg.is_visible_to("bob", None));
1137        assert!(msg.is_visible_to("carol", Some("dave")));
1138    }
1139
1140    #[test]
1141    fn join_always_visible() {
1142        let msg = make_join("r", "alice");
1143        assert!(msg.is_visible_to("bob", None));
1144    }
1145
1146    // ── Accessor tests ────────────────────────────────────────────────────────
1147
1148    #[test]
1149    fn accessors_return_correct_fields() {
1150        let ts = fixed_ts();
1151        let msg = Message::Message {
1152            id: fixed_id(),
1153            room: "testroom".into(),
1154            user: "carol".into(),
1155            ts,
1156            content: "x".into(),
1157            seq: None,
1158        };
1159        assert_eq!(msg.id(), fixed_id());
1160        assert_eq!(msg.room(), "testroom");
1161        assert_eq!(msg.user(), "carol");
1162        assert_eq!(msg.ts(), &fixed_ts());
1163    }
1164
1165    // ── RoomVisibility tests ──────────────────────────────────────────────────
1166
1167    #[test]
1168    fn room_visibility_serde_round_trip() {
1169        for vis in [
1170            RoomVisibility::Public,
1171            RoomVisibility::Private,
1172            RoomVisibility::Unlisted,
1173            RoomVisibility::Dm,
1174        ] {
1175            let json = serde_json::to_string(&vis).unwrap();
1176            let back: RoomVisibility = serde_json::from_str(&json).unwrap();
1177            assert_eq!(vis, back);
1178        }
1179    }
1180
1181    #[test]
1182    fn room_visibility_rename_all_snake_case() {
1183        assert_eq!(
1184            serde_json::to_string(&RoomVisibility::Public).unwrap(),
1185            r#""public""#
1186        );
1187        assert_eq!(
1188            serde_json::to_string(&RoomVisibility::Dm).unwrap(),
1189            r#""dm""#
1190        );
1191    }
1192
1193    // ── dm_room_id tests ──────────────────────────────────────────────────────
1194
1195    #[test]
1196    fn dm_room_id_sorts_alphabetically() {
1197        assert_eq!(dm_room_id("alice", "bob").unwrap(), "dm-alice-bob");
1198        assert_eq!(dm_room_id("bob", "alice").unwrap(), "dm-alice-bob");
1199    }
1200
1201    #[test]
1202    fn dm_room_id_same_user_errors() {
1203        let err = dm_room_id("alice", "alice").unwrap_err();
1204        assert_eq!(err, DmRoomError::SameUser("alice".to_owned()));
1205        assert_eq!(
1206            err.to_string(),
1207            "cannot create DM room: both users are 'alice'"
1208        );
1209    }
1210
1211    #[test]
1212    fn dm_room_id_is_deterministic() {
1213        let id1 = dm_room_id("r2d2", "saphire").unwrap();
1214        let id2 = dm_room_id("saphire", "r2d2").unwrap();
1215        assert_eq!(id1, id2);
1216        assert_eq!(id1, "dm-r2d2-saphire");
1217    }
1218
1219    #[test]
1220    fn dm_room_id_case_sensitive() {
1221        let id1 = dm_room_id("Alice", "bob").unwrap();
1222        let id2 = dm_room_id("alice", "bob").unwrap();
1223        // Uppercase sorts before lowercase in ASCII
1224        assert_eq!(id1, "dm-Alice-bob");
1225        assert_eq!(id2, "dm-alice-bob");
1226        assert_ne!(id1, id2);
1227    }
1228
1229    #[test]
1230    fn dm_room_id_with_hyphens_in_usernames() {
1231        let id = dm_room_id("my-agent", "your-bot").unwrap();
1232        assert_eq!(id, "dm-my-agent-your-bot");
1233    }
1234
1235    // ── is_dm_room tests ─────────────────────────────────────────────────────
1236
1237    #[test]
1238    fn is_dm_room_identifies_dm_rooms() {
1239        assert!(is_dm_room("dm-alice-bob"));
1240        assert!(is_dm_room("dm-r2d2-saphire"));
1241    }
1242
1243    #[test]
1244    fn is_dm_room_rejects_non_dm_rooms() {
1245        assert!(!is_dm_room("agent-room-2"));
1246        assert!(!is_dm_room("dev-chat"));
1247        assert!(!is_dm_room("dm"));
1248        assert!(!is_dm_room("dm-"));
1249        assert!(!is_dm_room(""));
1250    }
1251
1252    #[test]
1253    fn is_dm_room_handles_edge_cases() {
1254        // A room starting with "dm-" but having no second hyphen
1255        assert!(!is_dm_room("dm-onlyoneuser"));
1256        // Hyphenated usernames create more dashes — still valid
1257        assert!(is_dm_room("dm-my-agent-your-bot"));
1258    }
1259
1260    // ── DmRoomError tests ────────────────────────────────────────────────────
1261
1262    #[test]
1263    fn dm_room_error_display() {
1264        let err = DmRoomError::SameUser("bb".to_owned());
1265        assert_eq!(
1266            err.to_string(),
1267            "cannot create DM room: both users are 'bb'"
1268        );
1269    }
1270
1271    #[test]
1272    fn dm_room_error_is_send_sync() {
1273        fn assert_send_sync<T: Send + Sync>() {}
1274        assert_send_sync::<DmRoomError>();
1275    }
1276
1277    // ── RoomConfig tests ──────────────────────────────────────────────────────
1278
1279    #[test]
1280    fn room_config_public_defaults() {
1281        let config = RoomConfig::public("alice");
1282        assert_eq!(config.visibility, RoomVisibility::Public);
1283        assert!(config.max_members.is_none());
1284        assert!(config.invite_list.is_empty());
1285        assert_eq!(config.created_by, "alice");
1286    }
1287
1288    #[test]
1289    fn room_config_dm_has_two_users() {
1290        let config = RoomConfig::dm("alice", "bob");
1291        assert_eq!(config.visibility, RoomVisibility::Dm);
1292        assert_eq!(config.max_members, Some(2));
1293        assert!(config.invite_list.contains("alice"));
1294        assert!(config.invite_list.contains("bob"));
1295        assert_eq!(config.invite_list.len(), 2);
1296    }
1297
1298    #[test]
1299    fn room_config_serde_round_trip() {
1300        let config = RoomConfig::dm("alice", "bob");
1301        let json = serde_json::to_string(&config).unwrap();
1302        let back: RoomConfig = serde_json::from_str(&json).unwrap();
1303        assert_eq!(back.visibility, RoomVisibility::Dm);
1304        assert_eq!(back.max_members, Some(2));
1305        assert!(back.invite_list.contains("alice"));
1306        assert!(back.invite_list.contains("bob"));
1307    }
1308
1309    // ── RoomListEntry tests ───────────────────────────────────────────────────
1310
1311    #[test]
1312    fn room_list_entry_serde_round_trip() {
1313        let entry = RoomListEntry {
1314            room_id: "dev-chat".into(),
1315            visibility: RoomVisibility::Public,
1316            member_count: 5,
1317            created_by: "alice".into(),
1318        };
1319        let json = serde_json::to_string(&entry).unwrap();
1320        let back: RoomListEntry = serde_json::from_str(&json).unwrap();
1321        assert_eq!(back.room_id, "dev-chat");
1322        assert_eq!(back.visibility, RoomVisibility::Public);
1323        assert_eq!(back.member_count, 5);
1324    }
1325
1326    // ── parse_mentions tests ────────────────────────────────────────────────
1327
1328    #[test]
1329    fn parse_mentions_single() {
1330        assert_eq!(parse_mentions("hello @alice"), vec!["alice"]);
1331    }
1332
1333    #[test]
1334    fn parse_mentions_multiple() {
1335        assert_eq!(
1336            parse_mentions("@alice and @bob should see this"),
1337            vec!["alice", "bob"]
1338        );
1339    }
1340
1341    #[test]
1342    fn parse_mentions_at_start() {
1343        assert_eq!(parse_mentions("@alice hello"), vec!["alice"]);
1344    }
1345
1346    #[test]
1347    fn parse_mentions_at_end() {
1348        assert_eq!(parse_mentions("hello @alice"), vec!["alice"]);
1349    }
1350
1351    #[test]
1352    fn parse_mentions_with_hyphens_and_underscores() {
1353        assert_eq!(parse_mentions("cc @my-agent_2"), vec!["my-agent_2"]);
1354    }
1355
1356    #[test]
1357    fn parse_mentions_deduplicates() {
1358        assert_eq!(parse_mentions("@alice @bob @alice"), vec!["alice", "bob"]);
1359    }
1360
1361    #[test]
1362    fn parse_mentions_skips_email() {
1363        assert!(parse_mentions("send to user@example.com").is_empty());
1364    }
1365
1366    #[test]
1367    fn parse_mentions_skips_bare_at() {
1368        assert!(parse_mentions("@ alone").is_empty());
1369    }
1370
1371    #[test]
1372    fn parse_mentions_empty_content() {
1373        assert!(parse_mentions("").is_empty());
1374    }
1375
1376    #[test]
1377    fn parse_mentions_no_mentions() {
1378        assert!(parse_mentions("just a normal message").is_empty());
1379    }
1380
1381    #[test]
1382    fn parse_mentions_punctuation_after_username() {
1383        assert_eq!(parse_mentions("hey @alice, what's up?"), vec!["alice"]);
1384    }
1385
1386    #[test]
1387    fn parse_mentions_multiple_at_signs() {
1388        // user@@foo — second @ is preceded by non-whitespace, so skipped
1389        assert_eq!(parse_mentions("@alice@@bob"), vec!["alice"]);
1390    }
1391
1392    // ── content() and mentions() method tests ───────────────────────────────
1393
1394    #[test]
1395    fn message_content_returns_text() {
1396        let msg = make_message("r", "alice", "hello @bob");
1397        assert_eq!(msg.content(), Some("hello @bob"));
1398    }
1399
1400    #[test]
1401    fn join_content_returns_none() {
1402        let msg = make_join("r", "alice");
1403        assert!(msg.content().is_none());
1404    }
1405
1406    #[test]
1407    fn message_mentions_extracts_usernames() {
1408        let msg = make_message("r", "alice", "hey @bob and @carol");
1409        assert_eq!(msg.mentions(), vec!["bob", "carol"]);
1410    }
1411
1412    #[test]
1413    fn join_mentions_returns_empty() {
1414        let msg = make_join("r", "alice");
1415        assert!(msg.mentions().is_empty());
1416    }
1417
1418    #[test]
1419    fn dm_mentions_works() {
1420        let msg = make_dm("r", "alice", "bob", "cc @carol on this");
1421        assert_eq!(msg.mentions(), vec!["carol"]);
1422    }
1423
1424    #[test]
1425    fn reply_content_returns_text() {
1426        let msg = make_reply("r", "alice", "msg-1", "@bob noted");
1427        assert_eq!(msg.content(), Some("@bob noted"));
1428        assert_eq!(msg.mentions(), vec!["bob"]);
1429    }
1430
1431    // ── format_message_id / parse_message_id tests ───────────────────────────
1432
1433    #[test]
1434    fn format_message_id_basic() {
1435        assert_eq!(format_message_id("agent-room", 42), "agent-room:42");
1436    }
1437
1438    #[test]
1439    fn format_message_id_seq_zero() {
1440        assert_eq!(format_message_id("r", 0), "r:0");
1441    }
1442
1443    #[test]
1444    fn format_message_id_max_seq() {
1445        assert_eq!(format_message_id("r", u64::MAX), format!("r:{}", u64::MAX));
1446    }
1447
1448    #[test]
1449    fn parse_message_id_basic() {
1450        let (room, seq) = parse_message_id("agent-room:42").unwrap();
1451        assert_eq!(room, "agent-room");
1452        assert_eq!(seq, 42);
1453    }
1454
1455    #[test]
1456    fn parse_message_id_round_trips() {
1457        let id = format_message_id("dev-chat", 99);
1458        let (room, seq) = parse_message_id(&id).unwrap();
1459        assert_eq!(room, "dev-chat");
1460        assert_eq!(seq, 99);
1461    }
1462
1463    #[test]
1464    fn parse_message_id_room_with_colon() {
1465        // Room ID that itself contains a colon — split on last colon.
1466        let (room, seq) = parse_message_id("namespace:room:7").unwrap();
1467        assert_eq!(room, "namespace:room");
1468        assert_eq!(seq, 7);
1469    }
1470
1471    #[test]
1472    fn parse_message_id_no_colon_errors() {
1473        assert!(parse_message_id("nocolon").is_err());
1474    }
1475
1476    #[test]
1477    fn parse_message_id_invalid_seq_errors() {
1478        assert!(parse_message_id("room:notanumber").is_err());
1479    }
1480
1481    #[test]
1482    fn parse_message_id_negative_seq_errors() {
1483        // Negative numbers are not valid u64.
1484        assert!(parse_message_id("room:-1").is_err());
1485    }
1486
1487    #[test]
1488    fn parse_message_id_empty_room_ok() {
1489        // Edge case: empty room component.
1490        let (room, seq) = parse_message_id(":5").unwrap();
1491        assert_eq!(room, "");
1492        assert_eq!(seq, 5);
1493    }
1494
1495    // ── SubscriptionTier tests ───────────────────────────────────────────────
1496
1497    #[test]
1498    fn subscription_tier_serde_round_trip() {
1499        for tier in [
1500            SubscriptionTier::Full,
1501            SubscriptionTier::MentionsOnly,
1502            SubscriptionTier::Unsubscribed,
1503        ] {
1504            let json = serde_json::to_string(&tier).unwrap();
1505            let back: SubscriptionTier = serde_json::from_str(&json).unwrap();
1506            assert_eq!(tier, back);
1507        }
1508    }
1509
1510    #[test]
1511    fn subscription_tier_serde_snake_case() {
1512        assert_eq!(
1513            serde_json::to_string(&SubscriptionTier::Full).unwrap(),
1514            r#""full""#
1515        );
1516        assert_eq!(
1517            serde_json::to_string(&SubscriptionTier::MentionsOnly).unwrap(),
1518            r#""mentions_only""#
1519        );
1520        assert_eq!(
1521            serde_json::to_string(&SubscriptionTier::Unsubscribed).unwrap(),
1522            r#""unsubscribed""#
1523        );
1524    }
1525
1526    #[test]
1527    fn subscription_tier_display() {
1528        assert_eq!(SubscriptionTier::Full.to_string(), "full");
1529        assert_eq!(SubscriptionTier::MentionsOnly.to_string(), "mentions_only");
1530        assert_eq!(SubscriptionTier::Unsubscribed.to_string(), "unsubscribed");
1531    }
1532
1533    #[test]
1534    fn subscription_tier_from_str_canonical() {
1535        assert_eq!(
1536            "full".parse::<SubscriptionTier>().unwrap(),
1537            SubscriptionTier::Full
1538        );
1539        assert_eq!(
1540            "mentions_only".parse::<SubscriptionTier>().unwrap(),
1541            SubscriptionTier::MentionsOnly
1542        );
1543        assert_eq!(
1544            "unsubscribed".parse::<SubscriptionTier>().unwrap(),
1545            SubscriptionTier::Unsubscribed
1546        );
1547    }
1548
1549    #[test]
1550    fn subscription_tier_from_str_aliases() {
1551        assert_eq!(
1552            "mentions-only".parse::<SubscriptionTier>().unwrap(),
1553            SubscriptionTier::MentionsOnly
1554        );
1555        assert_eq!(
1556            "mentions".parse::<SubscriptionTier>().unwrap(),
1557            SubscriptionTier::MentionsOnly
1558        );
1559        assert_eq!(
1560            "none".parse::<SubscriptionTier>().unwrap(),
1561            SubscriptionTier::Unsubscribed
1562        );
1563    }
1564
1565    #[test]
1566    fn subscription_tier_from_str_invalid() {
1567        let err = "banana".parse::<SubscriptionTier>().unwrap_err();
1568        assert!(err.contains("unknown subscription tier"));
1569        assert!(err.contains("banana"));
1570    }
1571
1572    #[test]
1573    fn subscription_tier_display_round_trips_through_from_str() {
1574        for tier in [
1575            SubscriptionTier::Full,
1576            SubscriptionTier::MentionsOnly,
1577            SubscriptionTier::Unsubscribed,
1578        ] {
1579            let s = tier.to_string();
1580            let back: SubscriptionTier = s.parse().unwrap();
1581            assert_eq!(tier, back);
1582        }
1583    }
1584
1585    #[test]
1586    fn subscription_tier_is_copy() {
1587        let tier = SubscriptionTier::Full;
1588        let copy = tier;
1589        assert_eq!(tier, copy); // both valid — proves Copy
1590    }
1591
1592    // ── EventType tests ─────────────────────────────────────────────────────
1593
1594    #[test]
1595    fn event_type_serde_round_trip() {
1596        for et in [
1597            EventType::TaskPosted,
1598            EventType::TaskAssigned,
1599            EventType::TaskClaimed,
1600            EventType::TaskPlanned,
1601            EventType::TaskApproved,
1602            EventType::TaskUpdated,
1603            EventType::TaskReleased,
1604            EventType::TaskFinished,
1605            EventType::TaskCancelled,
1606            EventType::StatusChanged,
1607            EventType::ReviewRequested,
1608            EventType::ReviewClaimed,
1609            EventType::TaskRejected,
1610        ] {
1611            let json = serde_json::to_string(&et).unwrap();
1612            let back: EventType = serde_json::from_str(&json).unwrap();
1613            assert_eq!(et, back);
1614        }
1615    }
1616
1617    #[test]
1618    fn event_type_serde_snake_case() {
1619        assert_eq!(
1620            serde_json::to_string(&EventType::TaskPosted).unwrap(),
1621            r#""task_posted""#
1622        );
1623        assert_eq!(
1624            serde_json::to_string(&EventType::TaskAssigned).unwrap(),
1625            r#""task_assigned""#
1626        );
1627        assert_eq!(
1628            serde_json::to_string(&EventType::ReviewRequested).unwrap(),
1629            r#""review_requested""#
1630        );
1631    }
1632
1633    #[test]
1634    fn event_type_display() {
1635        assert_eq!(EventType::TaskPosted.to_string(), "task_posted");
1636        assert_eq!(EventType::TaskCancelled.to_string(), "task_cancelled");
1637        assert_eq!(EventType::StatusChanged.to_string(), "status_changed");
1638    }
1639
1640    #[test]
1641    fn event_type_is_copy() {
1642        let et = EventType::TaskPosted;
1643        let copy = et;
1644        assert_eq!(et, copy);
1645    }
1646
1647    #[test]
1648    fn event_type_from_str_all_variants() {
1649        let cases = [
1650            ("task_posted", EventType::TaskPosted),
1651            ("task_assigned", EventType::TaskAssigned),
1652            ("task_claimed", EventType::TaskClaimed),
1653            ("task_planned", EventType::TaskPlanned),
1654            ("task_approved", EventType::TaskApproved),
1655            ("task_updated", EventType::TaskUpdated),
1656            ("task_released", EventType::TaskReleased),
1657            ("task_finished", EventType::TaskFinished),
1658            ("task_cancelled", EventType::TaskCancelled),
1659            ("status_changed", EventType::StatusChanged),
1660            ("review_requested", EventType::ReviewRequested),
1661            ("review_claimed", EventType::ReviewClaimed),
1662            ("task_rejected", EventType::TaskRejected),
1663        ];
1664        for (s, expected) in cases {
1665            assert_eq!(s.parse::<EventType>().unwrap(), expected, "failed for {s}");
1666        }
1667    }
1668
1669    #[test]
1670    fn event_type_from_str_invalid() {
1671        let err = "banana".parse::<EventType>().unwrap_err();
1672        assert!(err.contains("unknown event type"));
1673        assert!(err.contains("banana"));
1674    }
1675
1676    #[test]
1677    fn event_type_display_round_trips_through_from_str() {
1678        for et in [
1679            EventType::TaskPosted,
1680            EventType::TaskAssigned,
1681            EventType::TaskClaimed,
1682            EventType::TaskPlanned,
1683            EventType::TaskApproved,
1684            EventType::TaskUpdated,
1685            EventType::TaskReleased,
1686            EventType::TaskFinished,
1687            EventType::TaskCancelled,
1688            EventType::StatusChanged,
1689            EventType::ReviewRequested,
1690            EventType::ReviewClaimed,
1691            EventType::TaskRejected,
1692        ] {
1693            let s = et.to_string();
1694            let back: EventType = s.parse().unwrap();
1695            assert_eq!(et, back);
1696        }
1697    }
1698
1699    #[test]
1700    fn event_type_ord_is_deterministic() {
1701        let mut v = vec![
1702            EventType::ReviewRequested,
1703            EventType::TaskPosted,
1704            EventType::TaskApproved,
1705        ];
1706        v.sort();
1707        // Sorted alphabetically by string representation
1708        assert_eq!(v[0], EventType::ReviewRequested);
1709        assert_eq!(v[1], EventType::TaskApproved);
1710        assert_eq!(v[2], EventType::TaskPosted);
1711    }
1712
1713    // ── EventFilter tests ───────────────────────────────────────────────────
1714
1715    #[test]
1716    fn event_filter_default_is_all() {
1717        assert_eq!(EventFilter::default(), EventFilter::All);
1718    }
1719
1720    #[test]
1721    fn event_filter_serde_all() {
1722        let f = EventFilter::All;
1723        let json = serde_json::to_string(&f).unwrap();
1724        let back: EventFilter = serde_json::from_str(&json).unwrap();
1725        assert_eq!(f, back);
1726        assert!(json.contains("\"all\""));
1727    }
1728
1729    #[test]
1730    fn event_filter_serde_none() {
1731        let f = EventFilter::None;
1732        let json = serde_json::to_string(&f).unwrap();
1733        let back: EventFilter = serde_json::from_str(&json).unwrap();
1734        assert_eq!(f, back);
1735        assert!(json.contains("\"none\""));
1736    }
1737
1738    #[test]
1739    fn event_filter_serde_only() {
1740        let mut types = BTreeSet::new();
1741        types.insert(EventType::TaskPosted);
1742        types.insert(EventType::TaskFinished);
1743        let f = EventFilter::Only { types };
1744        let json = serde_json::to_string(&f).unwrap();
1745        let back: EventFilter = serde_json::from_str(&json).unwrap();
1746        assert_eq!(f, back);
1747        assert!(json.contains("\"only\""));
1748        assert!(json.contains("task_posted"));
1749        assert!(json.contains("task_finished"));
1750    }
1751
1752    #[test]
1753    fn event_filter_display_all() {
1754        assert_eq!(EventFilter::All.to_string(), "all");
1755    }
1756
1757    #[test]
1758    fn event_filter_display_none() {
1759        assert_eq!(EventFilter::None.to_string(), "none");
1760    }
1761
1762    #[test]
1763    fn event_filter_display_only() {
1764        let mut types = BTreeSet::new();
1765        types.insert(EventType::TaskPosted);
1766        types.insert(EventType::TaskFinished);
1767        let f = EventFilter::Only { types };
1768        let display = f.to_string();
1769        // BTreeSet is sorted, so order is deterministic
1770        assert!(display.contains("task_finished"));
1771        assert!(display.contains("task_posted"));
1772    }
1773
1774    #[test]
1775    fn event_filter_from_str_all() {
1776        assert_eq!("all".parse::<EventFilter>().unwrap(), EventFilter::All);
1777    }
1778
1779    #[test]
1780    fn event_filter_from_str_none() {
1781        assert_eq!("none".parse::<EventFilter>().unwrap(), EventFilter::None);
1782    }
1783
1784    #[test]
1785    fn event_filter_from_str_empty_is_all() {
1786        assert_eq!("".parse::<EventFilter>().unwrap(), EventFilter::All);
1787    }
1788
1789    #[test]
1790    fn event_filter_from_str_csv() {
1791        let f: EventFilter = "task_posted,task_finished".parse().unwrap();
1792        let mut expected = BTreeSet::new();
1793        expected.insert(EventType::TaskPosted);
1794        expected.insert(EventType::TaskFinished);
1795        assert_eq!(f, EventFilter::Only { types: expected });
1796    }
1797
1798    #[test]
1799    fn event_filter_from_str_csv_with_spaces() {
1800        let f: EventFilter = "task_posted , task_finished".parse().unwrap();
1801        let mut expected = BTreeSet::new();
1802        expected.insert(EventType::TaskPosted);
1803        expected.insert(EventType::TaskFinished);
1804        assert_eq!(f, EventFilter::Only { types: expected });
1805    }
1806
1807    #[test]
1808    fn event_filter_from_str_single() {
1809        let f: EventFilter = "task_posted".parse().unwrap();
1810        let mut expected = BTreeSet::new();
1811        expected.insert(EventType::TaskPosted);
1812        assert_eq!(f, EventFilter::Only { types: expected });
1813    }
1814
1815    #[test]
1816    fn event_filter_from_str_invalid_type() {
1817        let err = "task_posted,banana".parse::<EventFilter>().unwrap_err();
1818        assert!(err.contains("unknown event type"));
1819        assert!(err.contains("banana"));
1820    }
1821
1822    #[test]
1823    fn event_filter_from_str_trailing_comma() {
1824        let f: EventFilter = "task_posted,".parse().unwrap();
1825        let mut expected = BTreeSet::new();
1826        expected.insert(EventType::TaskPosted);
1827        assert_eq!(f, EventFilter::Only { types: expected });
1828    }
1829
1830    #[test]
1831    fn event_filter_allows_all() {
1832        let f = EventFilter::All;
1833        assert!(f.allows(&EventType::TaskPosted));
1834        assert!(f.allows(&EventType::ReviewRequested));
1835    }
1836
1837    #[test]
1838    fn event_filter_allows_none() {
1839        let f = EventFilter::None;
1840        assert!(!f.allows(&EventType::TaskPosted));
1841        assert!(!f.allows(&EventType::ReviewRequested));
1842    }
1843
1844    #[test]
1845    fn event_filter_allows_only_matching() {
1846        let mut types = BTreeSet::new();
1847        types.insert(EventType::TaskPosted);
1848        types.insert(EventType::TaskFinished);
1849        let f = EventFilter::Only { types };
1850        assert!(f.allows(&EventType::TaskPosted));
1851        assert!(f.allows(&EventType::TaskFinished));
1852        assert!(!f.allows(&EventType::TaskAssigned));
1853        assert!(!f.allows(&EventType::ReviewRequested));
1854    }
1855
1856    #[test]
1857    fn event_filter_display_round_trips_through_from_str() {
1858        let filters = vec![EventFilter::All, EventFilter::None, {
1859            let mut types = BTreeSet::new();
1860            types.insert(EventType::TaskPosted);
1861            types.insert(EventType::TaskFinished);
1862            EventFilter::Only { types }
1863        }];
1864        for f in filters {
1865            let s = f.to_string();
1866            let back: EventFilter = s.parse().unwrap();
1867            assert_eq!(f, back, "round-trip failed for {s}");
1868        }
1869    }
1870
1871    // ── Event message tests ─────────────────────────────────────────────────
1872
1873    #[test]
1874    fn event_round_trips() {
1875        let msg = Message::Event {
1876            id: fixed_id(),
1877            room: "r".into(),
1878            user: "plugin:taskboard".into(),
1879            ts: fixed_ts(),
1880            seq: None,
1881            event_type: EventType::TaskAssigned,
1882            content: "task tb-001 claimed by agent".into(),
1883            params: None,
1884        };
1885        let json = serde_json::to_string(&msg).unwrap();
1886        let back: Message = serde_json::from_str(&json).unwrap();
1887        assert_eq!(msg, back);
1888    }
1889
1890    #[test]
1891    fn event_round_trips_with_params() {
1892        let params = serde_json::json!({"task_id": "tb-001", "assignee": "r2d2"});
1893        let msg = Message::Event {
1894            id: fixed_id(),
1895            room: "r".into(),
1896            user: "plugin:taskboard".into(),
1897            ts: fixed_ts(),
1898            seq: None,
1899            event_type: EventType::TaskAssigned,
1900            content: "task tb-001 assigned to r2d2".into(),
1901            params: Some(params),
1902        };
1903        let json = serde_json::to_string(&msg).unwrap();
1904        let back: Message = serde_json::from_str(&json).unwrap();
1905        assert_eq!(msg, back);
1906    }
1907
1908    #[test]
1909    fn event_json_has_type_and_event_type() {
1910        let msg = Message::Event {
1911            id: fixed_id(),
1912            room: "r".into(),
1913            user: "plugin:taskboard".into(),
1914            ts: fixed_ts(),
1915            seq: None,
1916            event_type: EventType::TaskFinished,
1917            content: "task done".into(),
1918            params: None,
1919        };
1920        let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
1921        assert_eq!(v["type"], "event");
1922        assert_eq!(v["event_type"], "task_finished");
1923        assert_eq!(v["content"], "task done");
1924        assert!(v.get("params").is_none(), "null params should be omitted");
1925    }
1926
1927    #[test]
1928    fn event_json_includes_params_when_present() {
1929        let msg = Message::Event {
1930            id: fixed_id(),
1931            room: "r".into(),
1932            user: "broker".into(),
1933            ts: fixed_ts(),
1934            seq: None,
1935            event_type: EventType::StatusChanged,
1936            content: "alice set status: busy".into(),
1937            params: Some(serde_json::json!({"user": "alice", "status": "busy"})),
1938        };
1939        let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
1940        assert_eq!(v["params"]["user"], "alice");
1941        assert_eq!(v["params"]["status"], "busy");
1942    }
1943
1944    #[test]
1945    fn deserialize_event_from_literal() {
1946        let raw = r#"{"type":"event","id":"abc","room":"r","user":"bot","ts":"2026-03-05T10:00:00Z","event_type":"task_posted","content":"posted"}"#;
1947        let msg: Message = serde_json::from_str(raw).unwrap();
1948        assert!(matches!(
1949            &msg,
1950            Message::Event { event_type, content, .. }
1951            if *event_type == EventType::TaskPosted && content == "posted"
1952        ));
1953    }
1954
1955    #[test]
1956    fn event_accessors_work() {
1957        let msg = make_event("r", "bot", EventType::TaskClaimed, "claimed", None);
1958        assert_eq!(msg.room(), "r");
1959        assert_eq!(msg.user(), "bot");
1960        assert_eq!(msg.content(), Some("claimed"));
1961        assert!(msg.seq().is_none());
1962    }
1963
1964    #[test]
1965    fn event_set_seq() {
1966        let mut msg = make_event("r", "bot", EventType::TaskPosted, "posted", None);
1967        msg.set_seq(42);
1968        assert_eq!(msg.seq(), Some(42));
1969    }
1970
1971    #[test]
1972    fn event_is_visible_to_everyone() {
1973        let msg = make_event("r", "bot", EventType::TaskFinished, "done", None);
1974        assert!(msg.is_visible_to("anyone", None));
1975        assert!(msg.is_visible_to("other", Some("host")));
1976    }
1977
1978    #[test]
1979    fn event_mentions_extracted() {
1980        let msg = make_event(
1981            "r",
1982            "plugin:taskboard",
1983            EventType::TaskAssigned,
1984            "task assigned to @r2d2 by @ba",
1985            None,
1986        );
1987        assert_eq!(msg.mentions(), vec!["r2d2", "ba"]);
1988    }
1989
1990    #[test]
1991    fn make_event_constructor() {
1992        let params = serde_json::json!({"key": "value"});
1993        let msg = make_event(
1994            "room1",
1995            "user1",
1996            EventType::ReviewRequested,
1997            "review pls",
1998            Some(params.clone()),
1999        );
2000        assert_eq!(msg.room(), "room1");
2001        assert_eq!(msg.user(), "user1");
2002        assert_eq!(msg.content(), Some("review pls"));
2003        if let Message::Event {
2004            event_type,
2005            params: p,
2006            ..
2007        } = &msg
2008        {
2009            assert_eq!(*event_type, EventType::ReviewRequested);
2010            assert_eq!(p.as_ref().unwrap(), &params);
2011        } else {
2012            panic!("expected Event variant");
2013        }
2014    }
2015}