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