Skip to main content

room_protocol/
lib.rs

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