1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[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 #[serde(rename = "dm")]
70 DirectMessage {
71 id: String,
72 room: String,
73 user: String,
75 ts: DateTime<Utc>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 seq: Option<u64>,
78 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 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 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
162fn 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
256pub 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#[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 #[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 #[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 #[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 #[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}