Skip to main content

room_protocol/
lib.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Wire format for all messages stored in the chat file and sent over the socket.
6///
7/// Uses `#[serde(tag = "type")]` internally-tagged enum **without** `#[serde(flatten)]`
8/// to avoid the serde flatten + internally-tagged footgun that breaks deserialization.
9/// Every variant carries its own id/room/user/ts fields.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11#[serde(tag = "type", rename_all = "snake_case")]
12pub enum Message {
13    Join {
14        id: String,
15        room: String,
16        user: String,
17        ts: DateTime<Utc>,
18        #[serde(default, skip_serializing_if = "Option::is_none")]
19        seq: Option<u64>,
20    },
21    Leave {
22        id: String,
23        room: String,
24        user: String,
25        ts: DateTime<Utc>,
26        #[serde(default, skip_serializing_if = "Option::is_none")]
27        seq: Option<u64>,
28    },
29    Message {
30        id: String,
31        room: String,
32        user: String,
33        ts: DateTime<Utc>,
34        #[serde(default, skip_serializing_if = "Option::is_none")]
35        seq: Option<u64>,
36        content: String,
37    },
38    Reply {
39        id: String,
40        room: String,
41        user: String,
42        ts: DateTime<Utc>,
43        #[serde(default, skip_serializing_if = "Option::is_none")]
44        seq: Option<u64>,
45        reply_to: String,
46        content: String,
47    },
48    Command {
49        id: String,
50        room: String,
51        user: String,
52        ts: DateTime<Utc>,
53        #[serde(default, skip_serializing_if = "Option::is_none")]
54        seq: Option<u64>,
55        cmd: String,
56        params: Vec<String>,
57    },
58    System {
59        id: String,
60        room: String,
61        user: String,
62        ts: DateTime<Utc>,
63        #[serde(default, skip_serializing_if = "Option::is_none")]
64        seq: Option<u64>,
65        content: String,
66    },
67    /// A private direct message. Delivered only to sender, recipient, and the
68    /// broker host. Always written to the chat history file.
69    #[serde(rename = "dm")]
70    DirectMessage {
71        id: String,
72        room: String,
73        /// Sender username (set by the broker).
74        user: String,
75        ts: DateTime<Utc>,
76        #[serde(default, skip_serializing_if = "Option::is_none")]
77        seq: Option<u64>,
78        /// Recipient username.
79        to: String,
80        content: String,
81    },
82}
83
84impl Message {
85    pub fn id(&self) -> &str {
86        match self {
87            Self::Join { id, .. }
88            | Self::Leave { id, .. }
89            | Self::Message { id, .. }
90            | Self::Reply { id, .. }
91            | Self::Command { id, .. }
92            | Self::System { id, .. }
93            | Self::DirectMessage { id, .. } => id,
94        }
95    }
96
97    pub fn room(&self) -> &str {
98        match self {
99            Self::Join { room, .. }
100            | Self::Leave { room, .. }
101            | Self::Message { room, .. }
102            | Self::Reply { room, .. }
103            | Self::Command { room, .. }
104            | Self::System { room, .. }
105            | Self::DirectMessage { room, .. } => room,
106        }
107    }
108
109    pub fn user(&self) -> &str {
110        match self {
111            Self::Join { user, .. }
112            | Self::Leave { user, .. }
113            | Self::Message { user, .. }
114            | Self::Reply { user, .. }
115            | Self::Command { user, .. }
116            | Self::System { user, .. }
117            | Self::DirectMessage { user, .. } => user,
118        }
119    }
120
121    pub fn ts(&self) -> &DateTime<Utc> {
122        match self {
123            Self::Join { ts, .. }
124            | Self::Leave { ts, .. }
125            | Self::Message { ts, .. }
126            | Self::Reply { ts, .. }
127            | Self::Command { ts, .. }
128            | Self::System { ts, .. }
129            | Self::DirectMessage { ts, .. } => ts,
130        }
131    }
132
133    /// Returns the sequence number assigned by the broker, or `None` for
134    /// messages loaded from history files that predate this feature.
135    pub fn seq(&self) -> Option<u64> {
136        match self {
137            Self::Join { seq, .. }
138            | Self::Leave { seq, .. }
139            | Self::Message { seq, .. }
140            | Self::Reply { seq, .. }
141            | Self::Command { seq, .. }
142            | Self::System { seq, .. }
143            | Self::DirectMessage { seq, .. } => *seq,
144        }
145    }
146
147    /// Assign a broker-issued sequence number to this message.
148    pub fn set_seq(&mut self, seq: u64) {
149        let n = Some(seq);
150        match self {
151            Self::Join { seq, .. } => *seq = n,
152            Self::Leave { seq, .. } => *seq = n,
153            Self::Message { seq, .. } => *seq = n,
154            Self::Reply { seq, .. } => *seq = n,
155            Self::Command { seq, .. } => *seq = n,
156            Self::System { seq, .. } => *seq = n,
157            Self::DirectMessage { seq, .. } => *seq = n,
158        }
159    }
160}
161
162// ── Constructors ─────────────────────────────────────────────────────────────
163
164fn new_id() -> String {
165    Uuid::new_v4().to_string()
166}
167
168pub fn make_join(room: &str, user: &str) -> Message {
169    Message::Join {
170        id: new_id(),
171        room: room.to_owned(),
172        user: user.to_owned(),
173        ts: Utc::now(),
174        seq: None,
175    }
176}
177
178pub fn make_leave(room: &str, user: &str) -> Message {
179    Message::Leave {
180        id: new_id(),
181        room: room.to_owned(),
182        user: user.to_owned(),
183        ts: Utc::now(),
184        seq: None,
185    }
186}
187
188pub fn make_message(room: &str, user: &str, content: impl Into<String>) -> Message {
189    Message::Message {
190        id: new_id(),
191        room: room.to_owned(),
192        user: user.to_owned(),
193        ts: Utc::now(),
194        content: content.into(),
195        seq: None,
196    }
197}
198
199pub fn make_reply(
200    room: &str,
201    user: &str,
202    reply_to: impl Into<String>,
203    content: impl Into<String>,
204) -> Message {
205    Message::Reply {
206        id: new_id(),
207        room: room.to_owned(),
208        user: user.to_owned(),
209        ts: Utc::now(),
210        reply_to: reply_to.into(),
211        content: content.into(),
212        seq: None,
213    }
214}
215
216pub fn make_command(
217    room: &str,
218    user: &str,
219    cmd: impl Into<String>,
220    params: Vec<String>,
221) -> Message {
222    Message::Command {
223        id: new_id(),
224        room: room.to_owned(),
225        user: user.to_owned(),
226        ts: Utc::now(),
227        cmd: cmd.into(),
228        params,
229        seq: None,
230    }
231}
232
233pub fn make_system(room: &str, user: &str, content: impl Into<String>) -> Message {
234    Message::System {
235        id: new_id(),
236        room: room.to_owned(),
237        user: user.to_owned(),
238        ts: Utc::now(),
239        content: content.into(),
240        seq: None,
241    }
242}
243
244pub fn make_dm(room: &str, user: &str, to: &str, content: impl Into<String>) -> Message {
245    Message::DirectMessage {
246        id: new_id(),
247        room: room.to_owned(),
248        user: user.to_owned(),
249        ts: Utc::now(),
250        to: to.to_owned(),
251        content: content.into(),
252        seq: None,
253    }
254}
255
256/// Parse a raw line from a client socket.
257/// JSON envelope → Message with broker-assigned id/room/ts.
258/// Plain text → Message::Message with broker-assigned metadata.
259pub fn parse_client_line(raw: &str, room: &str, user: &str) -> Result<Message, serde_json::Error> {
260    #[derive(Deserialize)]
261    #[serde(tag = "type", rename_all = "snake_case")]
262    enum Envelope {
263        Message {
264            content: String,
265        },
266        Reply {
267            reply_to: String,
268            content: String,
269        },
270        Command {
271            cmd: String,
272            params: Vec<String>,
273        },
274        #[serde(rename = "dm")]
275        Dm {
276            to: String,
277            content: String,
278        },
279    }
280
281    if raw.starts_with('{') {
282        let env: Envelope = serde_json::from_str(raw)?;
283        let msg = match env {
284            Envelope::Message { content } => make_message(room, user, content),
285            Envelope::Reply { reply_to, content } => make_reply(room, user, reply_to, content),
286            Envelope::Command { cmd, params } => make_command(room, user, cmd, params),
287            Envelope::Dm { to, content } => make_dm(room, user, &to, content),
288        };
289        Ok(msg)
290    } else {
291        Ok(make_message(room, user, raw))
292    }
293}
294
295// ── Tests ─────────────────────────────────────────────────────────────────────
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    fn fixed_ts() -> DateTime<Utc> {
302        use chrono::TimeZone;
303        Utc.with_ymd_and_hms(2026, 3, 5, 10, 0, 0).unwrap()
304    }
305
306    fn fixed_id() -> String {
307        "00000000-0000-0000-0000-000000000001".to_owned()
308    }
309
310    // ── Round-trip tests ─────────────────────────────────────────────────────
311
312    #[test]
313    fn join_round_trips() {
314        let msg = Message::Join {
315            id: fixed_id(),
316            room: "r".into(),
317            user: "alice".into(),
318            ts: fixed_ts(),
319            seq: None,
320        };
321        let json = serde_json::to_string(&msg).unwrap();
322        let back: Message = serde_json::from_str(&json).unwrap();
323        assert_eq!(msg, back);
324    }
325
326    #[test]
327    fn leave_round_trips() {
328        let msg = Message::Leave {
329            id: fixed_id(),
330            room: "r".into(),
331            user: "bob".into(),
332            ts: fixed_ts(),
333            seq: None,
334        };
335        let json = serde_json::to_string(&msg).unwrap();
336        let back: Message = serde_json::from_str(&json).unwrap();
337        assert_eq!(msg, back);
338    }
339
340    #[test]
341    fn message_round_trips() {
342        let msg = Message::Message {
343            id: fixed_id(),
344            room: "r".into(),
345            user: "alice".into(),
346            ts: fixed_ts(),
347            content: "hello world".into(),
348            seq: None,
349        };
350        let json = serde_json::to_string(&msg).unwrap();
351        let back: Message = serde_json::from_str(&json).unwrap();
352        assert_eq!(msg, back);
353    }
354
355    #[test]
356    fn reply_round_trips() {
357        let msg = Message::Reply {
358            id: fixed_id(),
359            room: "r".into(),
360            user: "bob".into(),
361            ts: fixed_ts(),
362            reply_to: "ffffffff-0000-0000-0000-000000000000".into(),
363            content: "pong".into(),
364            seq: None,
365        };
366        let json = serde_json::to_string(&msg).unwrap();
367        let back: Message = serde_json::from_str(&json).unwrap();
368        assert_eq!(msg, back);
369    }
370
371    #[test]
372    fn command_round_trips() {
373        let msg = Message::Command {
374            id: fixed_id(),
375            room: "r".into(),
376            user: "alice".into(),
377            ts: fixed_ts(),
378            cmd: "claim".into(),
379            params: vec!["task-123".into(), "fix the bug".into()],
380            seq: None,
381        };
382        let json = serde_json::to_string(&msg).unwrap();
383        let back: Message = serde_json::from_str(&json).unwrap();
384        assert_eq!(msg, back);
385    }
386
387    #[test]
388    fn system_round_trips() {
389        let msg = Message::System {
390            id: fixed_id(),
391            room: "r".into(),
392            user: "broker".into(),
393            ts: fixed_ts(),
394            content: "5 users online".into(),
395            seq: None,
396        };
397        let json = serde_json::to_string(&msg).unwrap();
398        let back: Message = serde_json::from_str(&json).unwrap();
399        assert_eq!(msg, back);
400    }
401
402    // ── JSON shape tests ─────────────────────────────────────────────────────
403
404    #[test]
405    fn join_json_has_type_field_at_top_level() {
406        let msg = Message::Join {
407            id: fixed_id(),
408            room: "r".into(),
409            user: "alice".into(),
410            ts: fixed_ts(),
411            seq: None,
412        };
413        let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
414        assert_eq!(v["type"], "join");
415        assert_eq!(v["user"], "alice");
416        assert_eq!(v["room"], "r");
417        assert!(
418            v.get("content").is_none(),
419            "join should not have content field"
420        );
421    }
422
423    #[test]
424    fn message_json_has_content_at_top_level() {
425        let msg = Message::Message {
426            id: fixed_id(),
427            room: "r".into(),
428            user: "alice".into(),
429            ts: fixed_ts(),
430            content: "hi".into(),
431            seq: None,
432        };
433        let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
434        assert_eq!(v["type"], "message");
435        assert_eq!(v["content"], "hi");
436    }
437
438    #[test]
439    fn deserialize_join_from_literal() {
440        let raw = r#"{"type":"join","id":"abc","room":"myroom","user":"alice","ts":"2026-03-05T10:00:00Z"}"#;
441        let msg: Message = serde_json::from_str(raw).unwrap();
442        assert!(matches!(msg, Message::Join { .. }));
443        assert_eq!(msg.user(), "alice");
444    }
445
446    #[test]
447    fn deserialize_message_from_literal() {
448        let raw = r#"{"type":"message","id":"abc","room":"r","user":"bob","ts":"2026-03-05T10:00:00Z","content":"yo"}"#;
449        let msg: Message = serde_json::from_str(raw).unwrap();
450        assert!(matches!(&msg, Message::Message { content, .. } if content == "yo"));
451    }
452
453    #[test]
454    fn deserialize_command_with_empty_params() {
455        let raw = r#"{"type":"command","id":"x","room":"r","user":"u","ts":"2026-03-05T10:00:00Z","cmd":"status","params":[]}"#;
456        let msg: Message = serde_json::from_str(raw).unwrap();
457        assert!(
458            matches!(&msg, Message::Command { cmd, params, .. } if cmd == "status" && params.is_empty())
459        );
460    }
461
462    // ── parse_client_line tests ───────────────────────────────────────────────
463
464    #[test]
465    fn parse_plain_text_becomes_message() {
466        let msg = parse_client_line("hello there", "myroom", "alice").unwrap();
467        assert!(matches!(&msg, Message::Message { content, .. } if content == "hello there"));
468        assert_eq!(msg.user(), "alice");
469        assert_eq!(msg.room(), "myroom");
470    }
471
472    #[test]
473    fn parse_json_message_envelope() {
474        let raw = r#"{"type":"message","content":"from agent"}"#;
475        let msg = parse_client_line(raw, "r", "bot1").unwrap();
476        assert!(matches!(&msg, Message::Message { content, .. } if content == "from agent"));
477    }
478
479    #[test]
480    fn parse_json_reply_envelope() {
481        let raw = r#"{"type":"reply","reply_to":"deadbeef","content":"ack"}"#;
482        let msg = parse_client_line(raw, "r", "bot1").unwrap();
483        assert!(
484            matches!(&msg, Message::Reply { reply_to, content, .. } if reply_to == "deadbeef" && content == "ack")
485        );
486    }
487
488    #[test]
489    fn parse_json_command_envelope() {
490        let raw = r#"{"type":"command","cmd":"claim","params":["task-42"]}"#;
491        let msg = parse_client_line(raw, "r", "agent").unwrap();
492        assert!(
493            matches!(&msg, Message::Command { cmd, params, .. } if cmd == "claim" && params == &["task-42"])
494        );
495    }
496
497    #[test]
498    fn parse_invalid_json_errors() {
499        let result = parse_client_line(r#"{"type":"unknown_type"}"#, "r", "u");
500        assert!(result.is_err());
501    }
502
503    #[test]
504    fn parse_dm_envelope() {
505        let raw = r#"{"type":"dm","to":"bob","content":"hey bob"}"#;
506        let msg = parse_client_line(raw, "r", "alice").unwrap();
507        assert!(
508            matches!(&msg, Message::DirectMessage { to, content, .. } if to == "bob" && content == "hey bob")
509        );
510        assert_eq!(msg.user(), "alice");
511    }
512
513    #[test]
514    fn dm_round_trips() {
515        let msg = Message::DirectMessage {
516            id: fixed_id(),
517            room: "r".into(),
518            user: "alice".into(),
519            ts: fixed_ts(),
520            to: "bob".into(),
521            content: "secret".into(),
522            seq: None,
523        };
524        let json = serde_json::to_string(&msg).unwrap();
525        let back: Message = serde_json::from_str(&json).unwrap();
526        assert_eq!(msg, back);
527    }
528
529    #[test]
530    fn dm_json_has_type_dm() {
531        let msg = Message::DirectMessage {
532            id: fixed_id(),
533            room: "r".into(),
534            user: "alice".into(),
535            ts: fixed_ts(),
536            to: "bob".into(),
537            content: "hi".into(),
538            seq: None,
539        };
540        let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
541        assert_eq!(v["type"], "dm");
542        assert_eq!(v["to"], "bob");
543        assert_eq!(v["content"], "hi");
544    }
545
546    // ── Accessor tests ────────────────────────────────────────────────────────
547
548    #[test]
549    fn accessors_return_correct_fields() {
550        let ts = fixed_ts();
551        let msg = Message::Message {
552            id: fixed_id(),
553            room: "testroom".into(),
554            user: "carol".into(),
555            ts,
556            content: "x".into(),
557            seq: None,
558        };
559        assert_eq!(msg.id(), fixed_id());
560        assert_eq!(msg.room(), "testroom");
561        assert_eq!(msg.user(), "carol");
562        assert_eq!(msg.ts(), &fixed_ts());
563    }
564}