1use std::collections::HashSet;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum RoomVisibility {
11 Public,
13 Private,
15 Unlisted,
17 Dm,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct RoomConfig {
24 pub visibility: RoomVisibility,
25 pub max_members: Option<usize>,
27 pub invite_list: HashSet<String>,
29 pub created_by: String,
31 pub created_at: String,
33}
34
35impl RoomConfig {
36 pub fn public(created_by: &str) -> Self {
38 Self {
39 visibility: RoomVisibility::Public,
40 max_members: None,
41 invite_list: HashSet::new(),
42 created_by: created_by.to_owned(),
43 created_at: Utc::now().to_rfc3339(),
44 }
45 }
46
47 pub fn dm(user_a: &str, user_b: &str) -> Self {
49 let mut invite_list = HashSet::new();
50 invite_list.insert(user_a.to_owned());
51 invite_list.insert(user_b.to_owned());
52 Self {
53 visibility: RoomVisibility::Dm,
54 max_members: Some(2),
55 invite_list,
56 created_by: user_a.to_owned(),
57 created_at: Utc::now().to_rfc3339(),
58 }
59 }
60}
61
62pub fn dm_room_id(user_a: &str, user_b: &str) -> String {
67 let (first, second) = if user_a < user_b {
68 (user_a, user_b)
69 } else {
70 (user_b, user_a)
71 };
72 format!("dm-{first}-{second}")
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct RoomListEntry {
78 pub room_id: String,
79 pub visibility: RoomVisibility,
80 pub member_count: usize,
81 pub created_by: String,
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90#[serde(tag = "type", rename_all = "snake_case")]
91pub enum Message {
92 Join {
93 id: String,
94 room: String,
95 user: String,
96 ts: DateTime<Utc>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 seq: Option<u64>,
99 },
100 Leave {
101 id: String,
102 room: String,
103 user: String,
104 ts: DateTime<Utc>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 seq: Option<u64>,
107 },
108 Message {
109 id: String,
110 room: String,
111 user: String,
112 ts: DateTime<Utc>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 seq: Option<u64>,
115 content: String,
116 },
117 Reply {
118 id: String,
119 room: String,
120 user: String,
121 ts: DateTime<Utc>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 seq: Option<u64>,
124 reply_to: String,
125 content: String,
126 },
127 Command {
128 id: String,
129 room: String,
130 user: String,
131 ts: DateTime<Utc>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 seq: Option<u64>,
134 cmd: String,
135 params: Vec<String>,
136 },
137 System {
138 id: String,
139 room: String,
140 user: String,
141 ts: DateTime<Utc>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 seq: Option<u64>,
144 content: String,
145 },
146 #[serde(rename = "dm")]
149 DirectMessage {
150 id: String,
151 room: String,
152 user: String,
154 ts: DateTime<Utc>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 seq: Option<u64>,
157 to: String,
159 content: String,
160 },
161}
162
163impl Message {
164 pub fn id(&self) -> &str {
165 match self {
166 Self::Join { id, .. }
167 | Self::Leave { id, .. }
168 | Self::Message { id, .. }
169 | Self::Reply { id, .. }
170 | Self::Command { id, .. }
171 | Self::System { id, .. }
172 | Self::DirectMessage { id, .. } => id,
173 }
174 }
175
176 pub fn room(&self) -> &str {
177 match self {
178 Self::Join { room, .. }
179 | Self::Leave { room, .. }
180 | Self::Message { room, .. }
181 | Self::Reply { room, .. }
182 | Self::Command { room, .. }
183 | Self::System { room, .. }
184 | Self::DirectMessage { room, .. } => room,
185 }
186 }
187
188 pub fn user(&self) -> &str {
189 match self {
190 Self::Join { user, .. }
191 | Self::Leave { user, .. }
192 | Self::Message { user, .. }
193 | Self::Reply { user, .. }
194 | Self::Command { user, .. }
195 | Self::System { user, .. }
196 | Self::DirectMessage { user, .. } => user,
197 }
198 }
199
200 pub fn ts(&self) -> &DateTime<Utc> {
201 match self {
202 Self::Join { ts, .. }
203 | Self::Leave { ts, .. }
204 | Self::Message { ts, .. }
205 | Self::Reply { ts, .. }
206 | Self::Command { ts, .. }
207 | Self::System { ts, .. }
208 | Self::DirectMessage { ts, .. } => ts,
209 }
210 }
211
212 pub fn seq(&self) -> Option<u64> {
215 match self {
216 Self::Join { seq, .. }
217 | Self::Leave { seq, .. }
218 | Self::Message { seq, .. }
219 | Self::Reply { seq, .. }
220 | Self::Command { seq, .. }
221 | Self::System { seq, .. }
222 | Self::DirectMessage { seq, .. } => *seq,
223 }
224 }
225
226 pub fn content(&self) -> Option<&str> {
229 match self {
230 Self::Message { content, .. }
231 | Self::Reply { content, .. }
232 | Self::System { content, .. }
233 | Self::DirectMessage { content, .. } => Some(content),
234 Self::Join { .. } | Self::Leave { .. } | Self::Command { .. } => None,
235 }
236 }
237
238 pub fn mentions(&self) -> Vec<String> {
243 match self.content() {
244 Some(content) => parse_mentions(content),
245 None => Vec::new(),
246 }
247 }
248
249 pub fn set_seq(&mut self, seq: u64) {
251 let n = Some(seq);
252 match self {
253 Self::Join { seq, .. } => *seq = n,
254 Self::Leave { seq, .. } => *seq = n,
255 Self::Message { seq, .. } => *seq = n,
256 Self::Reply { seq, .. } => *seq = n,
257 Self::Command { seq, .. } => *seq = n,
258 Self::System { seq, .. } => *seq = n,
259 Self::DirectMessage { seq, .. } => *seq = n,
260 }
261 }
262}
263
264fn new_id() -> String {
267 Uuid::new_v4().to_string()
268}
269
270pub fn make_join(room: &str, user: &str) -> Message {
271 Message::Join {
272 id: new_id(),
273 room: room.to_owned(),
274 user: user.to_owned(),
275 ts: Utc::now(),
276 seq: None,
277 }
278}
279
280pub fn make_leave(room: &str, user: &str) -> Message {
281 Message::Leave {
282 id: new_id(),
283 room: room.to_owned(),
284 user: user.to_owned(),
285 ts: Utc::now(),
286 seq: None,
287 }
288}
289
290pub fn make_message(room: &str, user: &str, content: impl Into<String>) -> Message {
291 Message::Message {
292 id: new_id(),
293 room: room.to_owned(),
294 user: user.to_owned(),
295 ts: Utc::now(),
296 content: content.into(),
297 seq: None,
298 }
299}
300
301pub fn make_reply(
302 room: &str,
303 user: &str,
304 reply_to: impl Into<String>,
305 content: impl Into<String>,
306) -> Message {
307 Message::Reply {
308 id: new_id(),
309 room: room.to_owned(),
310 user: user.to_owned(),
311 ts: Utc::now(),
312 reply_to: reply_to.into(),
313 content: content.into(),
314 seq: None,
315 }
316}
317
318pub fn make_command(
319 room: &str,
320 user: &str,
321 cmd: impl Into<String>,
322 params: Vec<String>,
323) -> Message {
324 Message::Command {
325 id: new_id(),
326 room: room.to_owned(),
327 user: user.to_owned(),
328 ts: Utc::now(),
329 cmd: cmd.into(),
330 params,
331 seq: None,
332 }
333}
334
335pub fn make_system(room: &str, user: &str, content: impl Into<String>) -> Message {
336 Message::System {
337 id: new_id(),
338 room: room.to_owned(),
339 user: user.to_owned(),
340 ts: Utc::now(),
341 content: content.into(),
342 seq: None,
343 }
344}
345
346pub fn make_dm(room: &str, user: &str, to: &str, content: impl Into<String>) -> Message {
347 Message::DirectMessage {
348 id: new_id(),
349 room: room.to_owned(),
350 user: user.to_owned(),
351 ts: Utc::now(),
352 to: to.to_owned(),
353 content: content.into(),
354 seq: None,
355 }
356}
357
358pub fn parse_mentions(content: &str) -> Vec<String> {
368 let mut mentions = Vec::new();
369 let mut seen = HashSet::new();
370
371 for (i, _) in content.match_indices('@') {
372 if i > 0 {
374 let prev = content.as_bytes()[i - 1];
375 if !prev.is_ascii_whitespace() {
376 continue;
377 }
378 }
379
380 let rest = &content[i + 1..];
382 let end = rest
383 .find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
384 .unwrap_or(rest.len());
385 let username = &rest[..end];
386
387 if !username.is_empty() && seen.insert(username.to_owned()) {
388 mentions.push(username.to_owned());
389 }
390 }
391
392 mentions
393}
394
395pub fn parse_client_line(raw: &str, room: &str, user: &str) -> Result<Message, serde_json::Error> {
399 #[derive(Deserialize)]
400 #[serde(tag = "type", rename_all = "snake_case")]
401 enum Envelope {
402 Message {
403 content: String,
404 },
405 Reply {
406 reply_to: String,
407 content: String,
408 },
409 Command {
410 cmd: String,
411 params: Vec<String>,
412 },
413 #[serde(rename = "dm")]
414 Dm {
415 to: String,
416 content: String,
417 },
418 }
419
420 if raw.starts_with('{') {
421 let env: Envelope = serde_json::from_str(raw)?;
422 let msg = match env {
423 Envelope::Message { content } => make_message(room, user, content),
424 Envelope::Reply { reply_to, content } => make_reply(room, user, reply_to, content),
425 Envelope::Command { cmd, params } => make_command(room, user, cmd, params),
426 Envelope::Dm { to, content } => make_dm(room, user, &to, content),
427 };
428 Ok(msg)
429 } else {
430 Ok(make_message(room, user, raw))
431 }
432}
433
434#[cfg(test)]
437mod tests {
438 use super::*;
439
440 fn fixed_ts() -> DateTime<Utc> {
441 use chrono::TimeZone;
442 Utc.with_ymd_and_hms(2026, 3, 5, 10, 0, 0).unwrap()
443 }
444
445 fn fixed_id() -> String {
446 "00000000-0000-0000-0000-000000000001".to_owned()
447 }
448
449 #[test]
452 fn join_round_trips() {
453 let msg = Message::Join {
454 id: fixed_id(),
455 room: "r".into(),
456 user: "alice".into(),
457 ts: fixed_ts(),
458 seq: None,
459 };
460 let json = serde_json::to_string(&msg).unwrap();
461 let back: Message = serde_json::from_str(&json).unwrap();
462 assert_eq!(msg, back);
463 }
464
465 #[test]
466 fn leave_round_trips() {
467 let msg = Message::Leave {
468 id: fixed_id(),
469 room: "r".into(),
470 user: "bob".into(),
471 ts: fixed_ts(),
472 seq: None,
473 };
474 let json = serde_json::to_string(&msg).unwrap();
475 let back: Message = serde_json::from_str(&json).unwrap();
476 assert_eq!(msg, back);
477 }
478
479 #[test]
480 fn message_round_trips() {
481 let msg = Message::Message {
482 id: fixed_id(),
483 room: "r".into(),
484 user: "alice".into(),
485 ts: fixed_ts(),
486 content: "hello world".into(),
487 seq: None,
488 };
489 let json = serde_json::to_string(&msg).unwrap();
490 let back: Message = serde_json::from_str(&json).unwrap();
491 assert_eq!(msg, back);
492 }
493
494 #[test]
495 fn reply_round_trips() {
496 let msg = Message::Reply {
497 id: fixed_id(),
498 room: "r".into(),
499 user: "bob".into(),
500 ts: fixed_ts(),
501 reply_to: "ffffffff-0000-0000-0000-000000000000".into(),
502 content: "pong".into(),
503 seq: None,
504 };
505 let json = serde_json::to_string(&msg).unwrap();
506 let back: Message = serde_json::from_str(&json).unwrap();
507 assert_eq!(msg, back);
508 }
509
510 #[test]
511 fn command_round_trips() {
512 let msg = Message::Command {
513 id: fixed_id(),
514 room: "r".into(),
515 user: "alice".into(),
516 ts: fixed_ts(),
517 cmd: "claim".into(),
518 params: vec!["task-123".into(), "fix the bug".into()],
519 seq: None,
520 };
521 let json = serde_json::to_string(&msg).unwrap();
522 let back: Message = serde_json::from_str(&json).unwrap();
523 assert_eq!(msg, back);
524 }
525
526 #[test]
527 fn system_round_trips() {
528 let msg = Message::System {
529 id: fixed_id(),
530 room: "r".into(),
531 user: "broker".into(),
532 ts: fixed_ts(),
533 content: "5 users online".into(),
534 seq: None,
535 };
536 let json = serde_json::to_string(&msg).unwrap();
537 let back: Message = serde_json::from_str(&json).unwrap();
538 assert_eq!(msg, back);
539 }
540
541 #[test]
544 fn join_json_has_type_field_at_top_level() {
545 let msg = Message::Join {
546 id: fixed_id(),
547 room: "r".into(),
548 user: "alice".into(),
549 ts: fixed_ts(),
550 seq: None,
551 };
552 let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
553 assert_eq!(v["type"], "join");
554 assert_eq!(v["user"], "alice");
555 assert_eq!(v["room"], "r");
556 assert!(
557 v.get("content").is_none(),
558 "join should not have content field"
559 );
560 }
561
562 #[test]
563 fn message_json_has_content_at_top_level() {
564 let msg = Message::Message {
565 id: fixed_id(),
566 room: "r".into(),
567 user: "alice".into(),
568 ts: fixed_ts(),
569 content: "hi".into(),
570 seq: None,
571 };
572 let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
573 assert_eq!(v["type"], "message");
574 assert_eq!(v["content"], "hi");
575 }
576
577 #[test]
578 fn deserialize_join_from_literal() {
579 let raw = r#"{"type":"join","id":"abc","room":"myroom","user":"alice","ts":"2026-03-05T10:00:00Z"}"#;
580 let msg: Message = serde_json::from_str(raw).unwrap();
581 assert!(matches!(msg, Message::Join { .. }));
582 assert_eq!(msg.user(), "alice");
583 }
584
585 #[test]
586 fn deserialize_message_from_literal() {
587 let raw = r#"{"type":"message","id":"abc","room":"r","user":"bob","ts":"2026-03-05T10:00:00Z","content":"yo"}"#;
588 let msg: Message = serde_json::from_str(raw).unwrap();
589 assert!(matches!(&msg, Message::Message { content, .. } if content == "yo"));
590 }
591
592 #[test]
593 fn deserialize_command_with_empty_params() {
594 let raw = r#"{"type":"command","id":"x","room":"r","user":"u","ts":"2026-03-05T10:00:00Z","cmd":"status","params":[]}"#;
595 let msg: Message = serde_json::from_str(raw).unwrap();
596 assert!(
597 matches!(&msg, Message::Command { cmd, params, .. } if cmd == "status" && params.is_empty())
598 );
599 }
600
601 #[test]
604 fn parse_plain_text_becomes_message() {
605 let msg = parse_client_line("hello there", "myroom", "alice").unwrap();
606 assert!(matches!(&msg, Message::Message { content, .. } if content == "hello there"));
607 assert_eq!(msg.user(), "alice");
608 assert_eq!(msg.room(), "myroom");
609 }
610
611 #[test]
612 fn parse_json_message_envelope() {
613 let raw = r#"{"type":"message","content":"from agent"}"#;
614 let msg = parse_client_line(raw, "r", "bot1").unwrap();
615 assert!(matches!(&msg, Message::Message { content, .. } if content == "from agent"));
616 }
617
618 #[test]
619 fn parse_json_reply_envelope() {
620 let raw = r#"{"type":"reply","reply_to":"deadbeef","content":"ack"}"#;
621 let msg = parse_client_line(raw, "r", "bot1").unwrap();
622 assert!(
623 matches!(&msg, Message::Reply { reply_to, content, .. } if reply_to == "deadbeef" && content == "ack")
624 );
625 }
626
627 #[test]
628 fn parse_json_command_envelope() {
629 let raw = r#"{"type":"command","cmd":"claim","params":["task-42"]}"#;
630 let msg = parse_client_line(raw, "r", "agent").unwrap();
631 assert!(
632 matches!(&msg, Message::Command { cmd, params, .. } if cmd == "claim" && params == &["task-42"])
633 );
634 }
635
636 #[test]
637 fn parse_invalid_json_errors() {
638 let result = parse_client_line(r#"{"type":"unknown_type"}"#, "r", "u");
639 assert!(result.is_err());
640 }
641
642 #[test]
643 fn parse_dm_envelope() {
644 let raw = r#"{"type":"dm","to":"bob","content":"hey bob"}"#;
645 let msg = parse_client_line(raw, "r", "alice").unwrap();
646 assert!(
647 matches!(&msg, Message::DirectMessage { to, content, .. } if to == "bob" && content == "hey bob")
648 );
649 assert_eq!(msg.user(), "alice");
650 }
651
652 #[test]
653 fn dm_round_trips() {
654 let msg = Message::DirectMessage {
655 id: fixed_id(),
656 room: "r".into(),
657 user: "alice".into(),
658 ts: fixed_ts(),
659 to: "bob".into(),
660 content: "secret".into(),
661 seq: None,
662 };
663 let json = serde_json::to_string(&msg).unwrap();
664 let back: Message = serde_json::from_str(&json).unwrap();
665 assert_eq!(msg, back);
666 }
667
668 #[test]
669 fn dm_json_has_type_dm() {
670 let msg = Message::DirectMessage {
671 id: fixed_id(),
672 room: "r".into(),
673 user: "alice".into(),
674 ts: fixed_ts(),
675 to: "bob".into(),
676 content: "hi".into(),
677 seq: None,
678 };
679 let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
680 assert_eq!(v["type"], "dm");
681 assert_eq!(v["to"], "bob");
682 assert_eq!(v["content"], "hi");
683 }
684
685 #[test]
688 fn accessors_return_correct_fields() {
689 let ts = fixed_ts();
690 let msg = Message::Message {
691 id: fixed_id(),
692 room: "testroom".into(),
693 user: "carol".into(),
694 ts,
695 content: "x".into(),
696 seq: None,
697 };
698 assert_eq!(msg.id(), fixed_id());
699 assert_eq!(msg.room(), "testroom");
700 assert_eq!(msg.user(), "carol");
701 assert_eq!(msg.ts(), &fixed_ts());
702 }
703
704 #[test]
707 fn room_visibility_serde_round_trip() {
708 for vis in [
709 RoomVisibility::Public,
710 RoomVisibility::Private,
711 RoomVisibility::Unlisted,
712 RoomVisibility::Dm,
713 ] {
714 let json = serde_json::to_string(&vis).unwrap();
715 let back: RoomVisibility = serde_json::from_str(&json).unwrap();
716 assert_eq!(vis, back);
717 }
718 }
719
720 #[test]
721 fn room_visibility_rename_all_snake_case() {
722 assert_eq!(
723 serde_json::to_string(&RoomVisibility::Public).unwrap(),
724 r#""public""#
725 );
726 assert_eq!(
727 serde_json::to_string(&RoomVisibility::Dm).unwrap(),
728 r#""dm""#
729 );
730 }
731
732 #[test]
735 fn dm_room_id_sorts_alphabetically() {
736 assert_eq!(dm_room_id("alice", "bob"), "dm-alice-bob");
737 assert_eq!(dm_room_id("bob", "alice"), "dm-alice-bob");
738 }
739
740 #[test]
741 fn dm_room_id_same_user() {
742 assert_eq!(dm_room_id("alice", "alice"), "dm-alice-alice");
744 }
745
746 #[test]
747 fn dm_room_id_is_deterministic() {
748 let id1 = dm_room_id("r2d2", "saphire");
749 let id2 = dm_room_id("saphire", "r2d2");
750 assert_eq!(id1, id2);
751 assert_eq!(id1, "dm-r2d2-saphire");
752 }
753
754 #[test]
757 fn room_config_public_defaults() {
758 let config = RoomConfig::public("alice");
759 assert_eq!(config.visibility, RoomVisibility::Public);
760 assert!(config.max_members.is_none());
761 assert!(config.invite_list.is_empty());
762 assert_eq!(config.created_by, "alice");
763 }
764
765 #[test]
766 fn room_config_dm_has_two_users() {
767 let config = RoomConfig::dm("alice", "bob");
768 assert_eq!(config.visibility, RoomVisibility::Dm);
769 assert_eq!(config.max_members, Some(2));
770 assert!(config.invite_list.contains("alice"));
771 assert!(config.invite_list.contains("bob"));
772 assert_eq!(config.invite_list.len(), 2);
773 }
774
775 #[test]
776 fn room_config_serde_round_trip() {
777 let config = RoomConfig::dm("alice", "bob");
778 let json = serde_json::to_string(&config).unwrap();
779 let back: RoomConfig = serde_json::from_str(&json).unwrap();
780 assert_eq!(back.visibility, RoomVisibility::Dm);
781 assert_eq!(back.max_members, Some(2));
782 assert!(back.invite_list.contains("alice"));
783 assert!(back.invite_list.contains("bob"));
784 }
785
786 #[test]
789 fn room_list_entry_serde_round_trip() {
790 let entry = RoomListEntry {
791 room_id: "dev-chat".into(),
792 visibility: RoomVisibility::Public,
793 member_count: 5,
794 created_by: "alice".into(),
795 };
796 let json = serde_json::to_string(&entry).unwrap();
797 let back: RoomListEntry = serde_json::from_str(&json).unwrap();
798 assert_eq!(back.room_id, "dev-chat");
799 assert_eq!(back.visibility, RoomVisibility::Public);
800 assert_eq!(back.member_count, 5);
801 }
802
803 #[test]
806 fn parse_mentions_single() {
807 assert_eq!(parse_mentions("hello @alice"), vec!["alice"]);
808 }
809
810 #[test]
811 fn parse_mentions_multiple() {
812 assert_eq!(
813 parse_mentions("@alice and @bob should see this"),
814 vec!["alice", "bob"]
815 );
816 }
817
818 #[test]
819 fn parse_mentions_at_start() {
820 assert_eq!(parse_mentions("@alice hello"), vec!["alice"]);
821 }
822
823 #[test]
824 fn parse_mentions_at_end() {
825 assert_eq!(parse_mentions("hello @alice"), vec!["alice"]);
826 }
827
828 #[test]
829 fn parse_mentions_with_hyphens_and_underscores() {
830 assert_eq!(parse_mentions("cc @my-agent_2"), vec!["my-agent_2"]);
831 }
832
833 #[test]
834 fn parse_mentions_deduplicates() {
835 assert_eq!(parse_mentions("@alice @bob @alice"), vec!["alice", "bob"]);
836 }
837
838 #[test]
839 fn parse_mentions_skips_email() {
840 assert!(parse_mentions("send to user@example.com").is_empty());
841 }
842
843 #[test]
844 fn parse_mentions_skips_bare_at() {
845 assert!(parse_mentions("@ alone").is_empty());
846 }
847
848 #[test]
849 fn parse_mentions_empty_content() {
850 assert!(parse_mentions("").is_empty());
851 }
852
853 #[test]
854 fn parse_mentions_no_mentions() {
855 assert!(parse_mentions("just a normal message").is_empty());
856 }
857
858 #[test]
859 fn parse_mentions_punctuation_after_username() {
860 assert_eq!(parse_mentions("hey @alice, what's up?"), vec!["alice"]);
861 }
862
863 #[test]
864 fn parse_mentions_multiple_at_signs() {
865 assert_eq!(parse_mentions("@alice@@bob"), vec!["alice"]);
867 }
868
869 #[test]
872 fn message_content_returns_text() {
873 let msg = make_message("r", "alice", "hello @bob");
874 assert_eq!(msg.content(), Some("hello @bob"));
875 }
876
877 #[test]
878 fn join_content_returns_none() {
879 let msg = make_join("r", "alice");
880 assert!(msg.content().is_none());
881 }
882
883 #[test]
884 fn message_mentions_extracts_usernames() {
885 let msg = make_message("r", "alice", "hey @bob and @carol");
886 assert_eq!(msg.mentions(), vec!["bob", "carol"]);
887 }
888
889 #[test]
890 fn join_mentions_returns_empty() {
891 let msg = make_join("r", "alice");
892 assert!(msg.mentions().is_empty());
893 }
894
895 #[test]
896 fn dm_mentions_works() {
897 let msg = make_dm("r", "alice", "bob", "cc @carol on this");
898 assert_eq!(msg.mentions(), vec!["carol"]);
899 }
900
901 #[test]
902 fn reply_content_returns_text() {
903 let msg = make_reply("r", "alice", "msg-1", "@bob noted");
904 assert_eq!(msg.content(), Some("@bob noted"));
905 assert_eq!(msg.mentions(), vec!["bob"]);
906 }
907}