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