1use std::collections::HashSet;
2use std::fmt;
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum DmRoomError {
11 SameUser(String),
13}
14
15impl fmt::Display for DmRoomError {
16 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17 match self {
18 DmRoomError::SameUser(user) => {
19 write!(f, "cannot create DM room: both users are '{user}'")
20 }
21 }
22 }
23}
24
25impl std::error::Error for DmRoomError {}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum RoomVisibility {
31 Public,
33 Private,
35 Unlisted,
37 Dm,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct RoomConfig {
44 pub visibility: RoomVisibility,
45 pub max_members: Option<usize>,
47 pub invite_list: HashSet<String>,
49 pub created_by: String,
51 pub created_at: String,
53}
54
55impl RoomConfig {
56 pub fn public(created_by: &str) -> Self {
58 Self {
59 visibility: RoomVisibility::Public,
60 max_members: None,
61 invite_list: HashSet::new(),
62 created_by: created_by.to_owned(),
63 created_at: Utc::now().to_rfc3339(),
64 }
65 }
66
67 pub fn dm(user_a: &str, user_b: &str) -> Self {
69 let mut invite_list = HashSet::new();
70 invite_list.insert(user_a.to_owned());
71 invite_list.insert(user_b.to_owned());
72 Self {
73 visibility: RoomVisibility::Dm,
74 max_members: Some(2),
75 invite_list,
76 created_by: user_a.to_owned(),
77 created_at: Utc::now().to_rfc3339(),
78 }
79 }
80}
81
82pub fn dm_room_id(user_a: &str, user_b: &str) -> Result<String, DmRoomError> {
91 if user_a == user_b {
92 return Err(DmRoomError::SameUser(user_a.to_owned()));
93 }
94 let (first, second) = if user_a < user_b {
95 (user_a, user_b)
96 } else {
97 (user_b, user_a)
98 };
99 Ok(format!("dm-{first}-{second}"))
100}
101
102pub fn is_dm_room(room_id: &str) -> bool {
107 room_id.starts_with("dm-") && room_id.matches('-').count() >= 2
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
117#[serde(rename_all = "snake_case")]
118pub enum SubscriptionTier {
119 Full,
120 MentionsOnly,
121 Unsubscribed,
122}
123
124impl std::fmt::Display for SubscriptionTier {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 match self {
127 Self::Full => write!(f, "full"),
128 Self::MentionsOnly => write!(f, "mentions_only"),
129 Self::Unsubscribed => write!(f, "unsubscribed"),
130 }
131 }
132}
133
134impl std::str::FromStr for SubscriptionTier {
135 type Err = String;
136
137 fn from_str(s: &str) -> Result<Self, Self::Err> {
138 match s {
139 "full" => Ok(Self::Full),
140 "mentions_only" | "mentions-only" | "mentions" => Ok(Self::MentionsOnly),
141 "unsubscribed" | "none" => Ok(Self::Unsubscribed),
142 other => Err(format!(
143 "unknown subscription tier '{other}'; expected full, mentions_only, or unsubscribed"
144 )),
145 }
146 }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct RoomListEntry {
152 pub room_id: String,
153 pub visibility: RoomVisibility,
154 pub member_count: usize,
155 pub created_by: String,
156}
157
158#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164#[serde(tag = "type", rename_all = "snake_case")]
165pub enum Message {
166 Join {
167 id: String,
168 room: String,
169 user: String,
170 ts: DateTime<Utc>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 seq: Option<u64>,
173 },
174 Leave {
175 id: String,
176 room: String,
177 user: String,
178 ts: DateTime<Utc>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 seq: Option<u64>,
181 },
182 Message {
183 id: String,
184 room: String,
185 user: String,
186 ts: DateTime<Utc>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
188 seq: Option<u64>,
189 content: String,
190 },
191 Reply {
192 id: String,
193 room: String,
194 user: String,
195 ts: DateTime<Utc>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 seq: Option<u64>,
198 reply_to: String,
199 content: String,
200 },
201 Command {
202 id: String,
203 room: String,
204 user: String,
205 ts: DateTime<Utc>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 seq: Option<u64>,
208 cmd: String,
209 params: Vec<String>,
210 },
211 System {
212 id: String,
213 room: String,
214 user: String,
215 ts: DateTime<Utc>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 seq: Option<u64>,
218 content: String,
219 },
220 #[serde(rename = "dm")]
223 DirectMessage {
224 id: String,
225 room: String,
226 user: String,
228 ts: DateTime<Utc>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 seq: Option<u64>,
231 to: String,
233 content: String,
234 },
235}
236
237impl Message {
238 pub fn id(&self) -> &str {
239 match self {
240 Self::Join { id, .. }
241 | Self::Leave { id, .. }
242 | Self::Message { id, .. }
243 | Self::Reply { id, .. }
244 | Self::Command { id, .. }
245 | Self::System { id, .. }
246 | Self::DirectMessage { id, .. } => id,
247 }
248 }
249
250 pub fn room(&self) -> &str {
251 match self {
252 Self::Join { room, .. }
253 | Self::Leave { room, .. }
254 | Self::Message { room, .. }
255 | Self::Reply { room, .. }
256 | Self::Command { room, .. }
257 | Self::System { room, .. }
258 | Self::DirectMessage { room, .. } => room,
259 }
260 }
261
262 pub fn user(&self) -> &str {
263 match self {
264 Self::Join { user, .. }
265 | Self::Leave { user, .. }
266 | Self::Message { user, .. }
267 | Self::Reply { user, .. }
268 | Self::Command { user, .. }
269 | Self::System { user, .. }
270 | Self::DirectMessage { user, .. } => user,
271 }
272 }
273
274 pub fn ts(&self) -> &DateTime<Utc> {
275 match self {
276 Self::Join { ts, .. }
277 | Self::Leave { ts, .. }
278 | Self::Message { ts, .. }
279 | Self::Reply { ts, .. }
280 | Self::Command { ts, .. }
281 | Self::System { ts, .. }
282 | Self::DirectMessage { ts, .. } => ts,
283 }
284 }
285
286 pub fn seq(&self) -> Option<u64> {
289 match self {
290 Self::Join { seq, .. }
291 | Self::Leave { seq, .. }
292 | Self::Message { seq, .. }
293 | Self::Reply { seq, .. }
294 | Self::Command { seq, .. }
295 | Self::System { seq, .. }
296 | Self::DirectMessage { seq, .. } => *seq,
297 }
298 }
299
300 pub fn content(&self) -> Option<&str> {
303 match self {
304 Self::Message { content, .. }
305 | Self::Reply { content, .. }
306 | Self::System { content, .. }
307 | Self::DirectMessage { content, .. } => Some(content),
308 Self::Join { .. } | Self::Leave { .. } | Self::Command { .. } => None,
309 }
310 }
311
312 pub fn mentions(&self) -> Vec<String> {
317 match self.content() {
318 Some(content) => parse_mentions(content),
319 None => Vec::new(),
320 }
321 }
322
323 pub fn is_visible_to(&self, viewer: &str, host: Option<&str>) -> bool {
329 match self {
330 Self::DirectMessage { user, to, .. } => {
331 viewer == user || viewer == to.as_str() || host == Some(viewer)
332 }
333 _ => true,
334 }
335 }
336
337 pub fn set_seq(&mut self, seq: u64) {
339 let n = Some(seq);
340 match self {
341 Self::Join { seq, .. } => *seq = n,
342 Self::Leave { seq, .. } => *seq = n,
343 Self::Message { seq, .. } => *seq = n,
344 Self::Reply { seq, .. } => *seq = n,
345 Self::Command { seq, .. } => *seq = n,
346 Self::System { seq, .. } => *seq = n,
347 Self::DirectMessage { seq, .. } => *seq = n,
348 }
349 }
350}
351
352fn new_id() -> String {
355 Uuid::new_v4().to_string()
356}
357
358pub fn make_join(room: &str, user: &str) -> Message {
359 Message::Join {
360 id: new_id(),
361 room: room.to_owned(),
362 user: user.to_owned(),
363 ts: Utc::now(),
364 seq: None,
365 }
366}
367
368pub fn make_leave(room: &str, user: &str) -> Message {
369 Message::Leave {
370 id: new_id(),
371 room: room.to_owned(),
372 user: user.to_owned(),
373 ts: Utc::now(),
374 seq: None,
375 }
376}
377
378pub fn make_message(room: &str, user: &str, content: impl Into<String>) -> Message {
379 Message::Message {
380 id: new_id(),
381 room: room.to_owned(),
382 user: user.to_owned(),
383 ts: Utc::now(),
384 content: content.into(),
385 seq: None,
386 }
387}
388
389pub fn make_reply(
390 room: &str,
391 user: &str,
392 reply_to: impl Into<String>,
393 content: impl Into<String>,
394) -> Message {
395 Message::Reply {
396 id: new_id(),
397 room: room.to_owned(),
398 user: user.to_owned(),
399 ts: Utc::now(),
400 reply_to: reply_to.into(),
401 content: content.into(),
402 seq: None,
403 }
404}
405
406pub fn make_command(
407 room: &str,
408 user: &str,
409 cmd: impl Into<String>,
410 params: Vec<String>,
411) -> Message {
412 Message::Command {
413 id: new_id(),
414 room: room.to_owned(),
415 user: user.to_owned(),
416 ts: Utc::now(),
417 cmd: cmd.into(),
418 params,
419 seq: None,
420 }
421}
422
423pub fn make_system(room: &str, user: &str, content: impl Into<String>) -> Message {
424 Message::System {
425 id: new_id(),
426 room: room.to_owned(),
427 user: user.to_owned(),
428 ts: Utc::now(),
429 content: content.into(),
430 seq: None,
431 }
432}
433
434pub fn make_dm(room: &str, user: &str, to: &str, content: impl Into<String>) -> Message {
435 Message::DirectMessage {
436 id: new_id(),
437 room: room.to_owned(),
438 user: user.to_owned(),
439 ts: Utc::now(),
440 to: to.to_owned(),
441 content: content.into(),
442 seq: None,
443 }
444}
445
446pub fn parse_mentions(content: &str) -> Vec<String> {
456 let mut mentions = Vec::new();
457 let mut seen = HashSet::new();
458
459 for (i, _) in content.match_indices('@') {
460 if i > 0 {
462 let prev = content.as_bytes()[i - 1];
463 if !prev.is_ascii_whitespace() {
464 continue;
465 }
466 }
467
468 let rest = &content[i + 1..];
470 let end = rest
471 .find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
472 .unwrap_or(rest.len());
473 let username = &rest[..end];
474
475 if !username.is_empty() && seen.insert(username.to_owned()) {
476 mentions.push(username.to_owned());
477 }
478 }
479
480 mentions
481}
482
483pub fn format_message_id(room: &str, seq: u64) -> String {
489 format!("{room}:{seq}")
490}
491
492pub fn parse_message_id(id: &str) -> Result<(String, u64), String> {
501 let colon = id
502 .rfind(':')
503 .ok_or_else(|| format!("no colon in message ID: {id:?}"))?;
504 let room = &id[..colon];
505 let seq_str = &id[colon + 1..];
506 let seq = seq_str
507 .parse::<u64>()
508 .map_err(|_| format!("invalid sequence number in message ID: {id:?}"))?;
509 Ok((room.to_owned(), seq))
510}
511
512pub fn parse_client_line(raw: &str, room: &str, user: &str) -> Result<Message, serde_json::Error> {
516 #[derive(Deserialize)]
517 #[serde(tag = "type", rename_all = "snake_case")]
518 enum Envelope {
519 Message {
520 content: String,
521 },
522 Reply {
523 reply_to: String,
524 content: String,
525 },
526 Command {
527 cmd: String,
528 params: Vec<String>,
529 },
530 #[serde(rename = "dm")]
531 Dm {
532 to: String,
533 content: String,
534 },
535 }
536
537 if raw.starts_with('{') {
538 let env: Envelope = serde_json::from_str(raw)?;
539 let msg = match env {
540 Envelope::Message { content } => make_message(room, user, content),
541 Envelope::Reply { reply_to, content } => make_reply(room, user, reply_to, content),
542 Envelope::Command { cmd, params } => make_command(room, user, cmd, params),
543 Envelope::Dm { to, content } => make_dm(room, user, &to, content),
544 };
545 Ok(msg)
546 } else {
547 Ok(make_message(room, user, raw))
548 }
549}
550
551#[cfg(test)]
554mod tests {
555 use super::*;
556
557 fn fixed_ts() -> DateTime<Utc> {
558 use chrono::TimeZone;
559 Utc.with_ymd_and_hms(2026, 3, 5, 10, 0, 0).unwrap()
560 }
561
562 fn fixed_id() -> String {
563 "00000000-0000-0000-0000-000000000001".to_owned()
564 }
565
566 #[test]
569 fn join_round_trips() {
570 let msg = Message::Join {
571 id: fixed_id(),
572 room: "r".into(),
573 user: "alice".into(),
574 ts: fixed_ts(),
575 seq: None,
576 };
577 let json = serde_json::to_string(&msg).unwrap();
578 let back: Message = serde_json::from_str(&json).unwrap();
579 assert_eq!(msg, back);
580 }
581
582 #[test]
583 fn leave_round_trips() {
584 let msg = Message::Leave {
585 id: fixed_id(),
586 room: "r".into(),
587 user: "bob".into(),
588 ts: fixed_ts(),
589 seq: None,
590 };
591 let json = serde_json::to_string(&msg).unwrap();
592 let back: Message = serde_json::from_str(&json).unwrap();
593 assert_eq!(msg, back);
594 }
595
596 #[test]
597 fn message_round_trips() {
598 let msg = Message::Message {
599 id: fixed_id(),
600 room: "r".into(),
601 user: "alice".into(),
602 ts: fixed_ts(),
603 content: "hello world".into(),
604 seq: None,
605 };
606 let json = serde_json::to_string(&msg).unwrap();
607 let back: Message = serde_json::from_str(&json).unwrap();
608 assert_eq!(msg, back);
609 }
610
611 #[test]
612 fn reply_round_trips() {
613 let msg = Message::Reply {
614 id: fixed_id(),
615 room: "r".into(),
616 user: "bob".into(),
617 ts: fixed_ts(),
618 reply_to: "ffffffff-0000-0000-0000-000000000000".into(),
619 content: "pong".into(),
620 seq: None,
621 };
622 let json = serde_json::to_string(&msg).unwrap();
623 let back: Message = serde_json::from_str(&json).unwrap();
624 assert_eq!(msg, back);
625 }
626
627 #[test]
628 fn command_round_trips() {
629 let msg = Message::Command {
630 id: fixed_id(),
631 room: "r".into(),
632 user: "alice".into(),
633 ts: fixed_ts(),
634 cmd: "claim".into(),
635 params: vec!["task-123".into(), "fix the bug".into()],
636 seq: None,
637 };
638 let json = serde_json::to_string(&msg).unwrap();
639 let back: Message = serde_json::from_str(&json).unwrap();
640 assert_eq!(msg, back);
641 }
642
643 #[test]
644 fn system_round_trips() {
645 let msg = Message::System {
646 id: fixed_id(),
647 room: "r".into(),
648 user: "broker".into(),
649 ts: fixed_ts(),
650 content: "5 users online".into(),
651 seq: None,
652 };
653 let json = serde_json::to_string(&msg).unwrap();
654 let back: Message = serde_json::from_str(&json).unwrap();
655 assert_eq!(msg, back);
656 }
657
658 #[test]
661 fn join_json_has_type_field_at_top_level() {
662 let msg = Message::Join {
663 id: fixed_id(),
664 room: "r".into(),
665 user: "alice".into(),
666 ts: fixed_ts(),
667 seq: None,
668 };
669 let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
670 assert_eq!(v["type"], "join");
671 assert_eq!(v["user"], "alice");
672 assert_eq!(v["room"], "r");
673 assert!(
674 v.get("content").is_none(),
675 "join should not have content field"
676 );
677 }
678
679 #[test]
680 fn message_json_has_content_at_top_level() {
681 let msg = Message::Message {
682 id: fixed_id(),
683 room: "r".into(),
684 user: "alice".into(),
685 ts: fixed_ts(),
686 content: "hi".into(),
687 seq: None,
688 };
689 let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
690 assert_eq!(v["type"], "message");
691 assert_eq!(v["content"], "hi");
692 }
693
694 #[test]
695 fn deserialize_join_from_literal() {
696 let raw = r#"{"type":"join","id":"abc","room":"myroom","user":"alice","ts":"2026-03-05T10:00:00Z"}"#;
697 let msg: Message = serde_json::from_str(raw).unwrap();
698 assert!(matches!(msg, Message::Join { .. }));
699 assert_eq!(msg.user(), "alice");
700 }
701
702 #[test]
703 fn deserialize_message_from_literal() {
704 let raw = r#"{"type":"message","id":"abc","room":"r","user":"bob","ts":"2026-03-05T10:00:00Z","content":"yo"}"#;
705 let msg: Message = serde_json::from_str(raw).unwrap();
706 assert!(matches!(&msg, Message::Message { content, .. } if content == "yo"));
707 }
708
709 #[test]
710 fn deserialize_command_with_empty_params() {
711 let raw = r#"{"type":"command","id":"x","room":"r","user":"u","ts":"2026-03-05T10:00:00Z","cmd":"status","params":[]}"#;
712 let msg: Message = serde_json::from_str(raw).unwrap();
713 assert!(
714 matches!(&msg, Message::Command { cmd, params, .. } if cmd == "status" && params.is_empty())
715 );
716 }
717
718 #[test]
721 fn parse_plain_text_becomes_message() {
722 let msg = parse_client_line("hello there", "myroom", "alice").unwrap();
723 assert!(matches!(&msg, Message::Message { content, .. } if content == "hello there"));
724 assert_eq!(msg.user(), "alice");
725 assert_eq!(msg.room(), "myroom");
726 }
727
728 #[test]
729 fn parse_json_message_envelope() {
730 let raw = r#"{"type":"message","content":"from agent"}"#;
731 let msg = parse_client_line(raw, "r", "bot1").unwrap();
732 assert!(matches!(&msg, Message::Message { content, .. } if content == "from agent"));
733 }
734
735 #[test]
736 fn parse_json_reply_envelope() {
737 let raw = r#"{"type":"reply","reply_to":"deadbeef","content":"ack"}"#;
738 let msg = parse_client_line(raw, "r", "bot1").unwrap();
739 assert!(
740 matches!(&msg, Message::Reply { reply_to, content, .. } if reply_to == "deadbeef" && content == "ack")
741 );
742 }
743
744 #[test]
745 fn parse_json_command_envelope() {
746 let raw = r#"{"type":"command","cmd":"claim","params":["task-42"]}"#;
747 let msg = parse_client_line(raw, "r", "agent").unwrap();
748 assert!(
749 matches!(&msg, Message::Command { cmd, params, .. } if cmd == "claim" && params == &["task-42"])
750 );
751 }
752
753 #[test]
754 fn parse_invalid_json_errors() {
755 let result = parse_client_line(r#"{"type":"unknown_type"}"#, "r", "u");
756 assert!(result.is_err());
757 }
758
759 #[test]
760 fn parse_dm_envelope() {
761 let raw = r#"{"type":"dm","to":"bob","content":"hey bob"}"#;
762 let msg = parse_client_line(raw, "r", "alice").unwrap();
763 assert!(
764 matches!(&msg, Message::DirectMessage { to, content, .. } if to == "bob" && content == "hey bob")
765 );
766 assert_eq!(msg.user(), "alice");
767 }
768
769 #[test]
770 fn dm_round_trips() {
771 let msg = Message::DirectMessage {
772 id: fixed_id(),
773 room: "r".into(),
774 user: "alice".into(),
775 ts: fixed_ts(),
776 to: "bob".into(),
777 content: "secret".into(),
778 seq: None,
779 };
780 let json = serde_json::to_string(&msg).unwrap();
781 let back: Message = serde_json::from_str(&json).unwrap();
782 assert_eq!(msg, back);
783 }
784
785 #[test]
786 fn dm_json_has_type_dm() {
787 let msg = Message::DirectMessage {
788 id: fixed_id(),
789 room: "r".into(),
790 user: "alice".into(),
791 ts: fixed_ts(),
792 to: "bob".into(),
793 content: "hi".into(),
794 seq: None,
795 };
796 let v: serde_json::Value = serde_json::to_value(&msg).unwrap();
797 assert_eq!(v["type"], "dm");
798 assert_eq!(v["to"], "bob");
799 assert_eq!(v["content"], "hi");
800 }
801
802 fn make_test_dm(from: &str, to: &str) -> Message {
805 Message::DirectMessage {
806 id: fixed_id(),
807 room: "r".into(),
808 user: from.into(),
809 ts: fixed_ts(),
810 seq: None,
811 to: to.into(),
812 content: "secret".into(),
813 }
814 }
815
816 #[test]
817 fn dm_visible_to_sender() {
818 let msg = make_test_dm("alice", "bob");
819 assert!(msg.is_visible_to("alice", None));
820 }
821
822 #[test]
823 fn dm_visible_to_recipient() {
824 let msg = make_test_dm("alice", "bob");
825 assert!(msg.is_visible_to("bob", None));
826 }
827
828 #[test]
829 fn dm_visible_to_host() {
830 let msg = make_test_dm("alice", "bob");
831 assert!(msg.is_visible_to("carol", Some("carol")));
832 }
833
834 #[test]
835 fn dm_hidden_from_non_participant() {
836 let msg = make_test_dm("alice", "bob");
837 assert!(!msg.is_visible_to("carol", None));
838 }
839
840 #[test]
841 fn dm_non_participant_not_elevated_by_different_host() {
842 let msg = make_test_dm("alice", "bob");
843 assert!(!msg.is_visible_to("carol", Some("dave")));
844 }
845
846 #[test]
847 fn non_dm_always_visible() {
848 let msg = make_message("r", "alice", "hello");
849 assert!(msg.is_visible_to("bob", None));
850 assert!(msg.is_visible_to("carol", Some("dave")));
851 }
852
853 #[test]
854 fn join_always_visible() {
855 let msg = make_join("r", "alice");
856 assert!(msg.is_visible_to("bob", None));
857 }
858
859 #[test]
862 fn accessors_return_correct_fields() {
863 let ts = fixed_ts();
864 let msg = Message::Message {
865 id: fixed_id(),
866 room: "testroom".into(),
867 user: "carol".into(),
868 ts,
869 content: "x".into(),
870 seq: None,
871 };
872 assert_eq!(msg.id(), fixed_id());
873 assert_eq!(msg.room(), "testroom");
874 assert_eq!(msg.user(), "carol");
875 assert_eq!(msg.ts(), &fixed_ts());
876 }
877
878 #[test]
881 fn room_visibility_serde_round_trip() {
882 for vis in [
883 RoomVisibility::Public,
884 RoomVisibility::Private,
885 RoomVisibility::Unlisted,
886 RoomVisibility::Dm,
887 ] {
888 let json = serde_json::to_string(&vis).unwrap();
889 let back: RoomVisibility = serde_json::from_str(&json).unwrap();
890 assert_eq!(vis, back);
891 }
892 }
893
894 #[test]
895 fn room_visibility_rename_all_snake_case() {
896 assert_eq!(
897 serde_json::to_string(&RoomVisibility::Public).unwrap(),
898 r#""public""#
899 );
900 assert_eq!(
901 serde_json::to_string(&RoomVisibility::Dm).unwrap(),
902 r#""dm""#
903 );
904 }
905
906 #[test]
909 fn dm_room_id_sorts_alphabetically() {
910 assert_eq!(dm_room_id("alice", "bob").unwrap(), "dm-alice-bob");
911 assert_eq!(dm_room_id("bob", "alice").unwrap(), "dm-alice-bob");
912 }
913
914 #[test]
915 fn dm_room_id_same_user_errors() {
916 let err = dm_room_id("alice", "alice").unwrap_err();
917 assert_eq!(err, DmRoomError::SameUser("alice".to_owned()));
918 assert_eq!(
919 err.to_string(),
920 "cannot create DM room: both users are 'alice'"
921 );
922 }
923
924 #[test]
925 fn dm_room_id_is_deterministic() {
926 let id1 = dm_room_id("r2d2", "saphire").unwrap();
927 let id2 = dm_room_id("saphire", "r2d2").unwrap();
928 assert_eq!(id1, id2);
929 assert_eq!(id1, "dm-r2d2-saphire");
930 }
931
932 #[test]
933 fn dm_room_id_case_sensitive() {
934 let id1 = dm_room_id("Alice", "bob").unwrap();
935 let id2 = dm_room_id("alice", "bob").unwrap();
936 assert_eq!(id1, "dm-Alice-bob");
938 assert_eq!(id2, "dm-alice-bob");
939 assert_ne!(id1, id2);
940 }
941
942 #[test]
943 fn dm_room_id_with_hyphens_in_usernames() {
944 let id = dm_room_id("my-agent", "your-bot").unwrap();
945 assert_eq!(id, "dm-my-agent-your-bot");
946 }
947
948 #[test]
951 fn is_dm_room_identifies_dm_rooms() {
952 assert!(is_dm_room("dm-alice-bob"));
953 assert!(is_dm_room("dm-r2d2-saphire"));
954 }
955
956 #[test]
957 fn is_dm_room_rejects_non_dm_rooms() {
958 assert!(!is_dm_room("agent-room-2"));
959 assert!(!is_dm_room("dev-chat"));
960 assert!(!is_dm_room("dm"));
961 assert!(!is_dm_room("dm-"));
962 assert!(!is_dm_room(""));
963 }
964
965 #[test]
966 fn is_dm_room_handles_edge_cases() {
967 assert!(!is_dm_room("dm-onlyoneuser"));
969 assert!(is_dm_room("dm-my-agent-your-bot"));
971 }
972
973 #[test]
976 fn dm_room_error_display() {
977 let err = DmRoomError::SameUser("bb".to_owned());
978 assert_eq!(
979 err.to_string(),
980 "cannot create DM room: both users are 'bb'"
981 );
982 }
983
984 #[test]
985 fn dm_room_error_is_send_sync() {
986 fn assert_send_sync<T: Send + Sync>() {}
987 assert_send_sync::<DmRoomError>();
988 }
989
990 #[test]
993 fn room_config_public_defaults() {
994 let config = RoomConfig::public("alice");
995 assert_eq!(config.visibility, RoomVisibility::Public);
996 assert!(config.max_members.is_none());
997 assert!(config.invite_list.is_empty());
998 assert_eq!(config.created_by, "alice");
999 }
1000
1001 #[test]
1002 fn room_config_dm_has_two_users() {
1003 let config = RoomConfig::dm("alice", "bob");
1004 assert_eq!(config.visibility, RoomVisibility::Dm);
1005 assert_eq!(config.max_members, Some(2));
1006 assert!(config.invite_list.contains("alice"));
1007 assert!(config.invite_list.contains("bob"));
1008 assert_eq!(config.invite_list.len(), 2);
1009 }
1010
1011 #[test]
1012 fn room_config_serde_round_trip() {
1013 let config = RoomConfig::dm("alice", "bob");
1014 let json = serde_json::to_string(&config).unwrap();
1015 let back: RoomConfig = serde_json::from_str(&json).unwrap();
1016 assert_eq!(back.visibility, RoomVisibility::Dm);
1017 assert_eq!(back.max_members, Some(2));
1018 assert!(back.invite_list.contains("alice"));
1019 assert!(back.invite_list.contains("bob"));
1020 }
1021
1022 #[test]
1025 fn room_list_entry_serde_round_trip() {
1026 let entry = RoomListEntry {
1027 room_id: "dev-chat".into(),
1028 visibility: RoomVisibility::Public,
1029 member_count: 5,
1030 created_by: "alice".into(),
1031 };
1032 let json = serde_json::to_string(&entry).unwrap();
1033 let back: RoomListEntry = serde_json::from_str(&json).unwrap();
1034 assert_eq!(back.room_id, "dev-chat");
1035 assert_eq!(back.visibility, RoomVisibility::Public);
1036 assert_eq!(back.member_count, 5);
1037 }
1038
1039 #[test]
1042 fn parse_mentions_single() {
1043 assert_eq!(parse_mentions("hello @alice"), vec!["alice"]);
1044 }
1045
1046 #[test]
1047 fn parse_mentions_multiple() {
1048 assert_eq!(
1049 parse_mentions("@alice and @bob should see this"),
1050 vec!["alice", "bob"]
1051 );
1052 }
1053
1054 #[test]
1055 fn parse_mentions_at_start() {
1056 assert_eq!(parse_mentions("@alice hello"), vec!["alice"]);
1057 }
1058
1059 #[test]
1060 fn parse_mentions_at_end() {
1061 assert_eq!(parse_mentions("hello @alice"), vec!["alice"]);
1062 }
1063
1064 #[test]
1065 fn parse_mentions_with_hyphens_and_underscores() {
1066 assert_eq!(parse_mentions("cc @my-agent_2"), vec!["my-agent_2"]);
1067 }
1068
1069 #[test]
1070 fn parse_mentions_deduplicates() {
1071 assert_eq!(parse_mentions("@alice @bob @alice"), vec!["alice", "bob"]);
1072 }
1073
1074 #[test]
1075 fn parse_mentions_skips_email() {
1076 assert!(parse_mentions("send to user@example.com").is_empty());
1077 }
1078
1079 #[test]
1080 fn parse_mentions_skips_bare_at() {
1081 assert!(parse_mentions("@ alone").is_empty());
1082 }
1083
1084 #[test]
1085 fn parse_mentions_empty_content() {
1086 assert!(parse_mentions("").is_empty());
1087 }
1088
1089 #[test]
1090 fn parse_mentions_no_mentions() {
1091 assert!(parse_mentions("just a normal message").is_empty());
1092 }
1093
1094 #[test]
1095 fn parse_mentions_punctuation_after_username() {
1096 assert_eq!(parse_mentions("hey @alice, what's up?"), vec!["alice"]);
1097 }
1098
1099 #[test]
1100 fn parse_mentions_multiple_at_signs() {
1101 assert_eq!(parse_mentions("@alice@@bob"), vec!["alice"]);
1103 }
1104
1105 #[test]
1108 fn message_content_returns_text() {
1109 let msg = make_message("r", "alice", "hello @bob");
1110 assert_eq!(msg.content(), Some("hello @bob"));
1111 }
1112
1113 #[test]
1114 fn join_content_returns_none() {
1115 let msg = make_join("r", "alice");
1116 assert!(msg.content().is_none());
1117 }
1118
1119 #[test]
1120 fn message_mentions_extracts_usernames() {
1121 let msg = make_message("r", "alice", "hey @bob and @carol");
1122 assert_eq!(msg.mentions(), vec!["bob", "carol"]);
1123 }
1124
1125 #[test]
1126 fn join_mentions_returns_empty() {
1127 let msg = make_join("r", "alice");
1128 assert!(msg.mentions().is_empty());
1129 }
1130
1131 #[test]
1132 fn dm_mentions_works() {
1133 let msg = make_dm("r", "alice", "bob", "cc @carol on this");
1134 assert_eq!(msg.mentions(), vec!["carol"]);
1135 }
1136
1137 #[test]
1138 fn reply_content_returns_text() {
1139 let msg = make_reply("r", "alice", "msg-1", "@bob noted");
1140 assert_eq!(msg.content(), Some("@bob noted"));
1141 assert_eq!(msg.mentions(), vec!["bob"]);
1142 }
1143
1144 #[test]
1147 fn format_message_id_basic() {
1148 assert_eq!(format_message_id("agent-room", 42), "agent-room:42");
1149 }
1150
1151 #[test]
1152 fn format_message_id_seq_zero() {
1153 assert_eq!(format_message_id("r", 0), "r:0");
1154 }
1155
1156 #[test]
1157 fn format_message_id_max_seq() {
1158 assert_eq!(format_message_id("r", u64::MAX), format!("r:{}", u64::MAX));
1159 }
1160
1161 #[test]
1162 fn parse_message_id_basic() {
1163 let (room, seq) = parse_message_id("agent-room:42").unwrap();
1164 assert_eq!(room, "agent-room");
1165 assert_eq!(seq, 42);
1166 }
1167
1168 #[test]
1169 fn parse_message_id_round_trips() {
1170 let id = format_message_id("dev-chat", 99);
1171 let (room, seq) = parse_message_id(&id).unwrap();
1172 assert_eq!(room, "dev-chat");
1173 assert_eq!(seq, 99);
1174 }
1175
1176 #[test]
1177 fn parse_message_id_room_with_colon() {
1178 let (room, seq) = parse_message_id("namespace:room:7").unwrap();
1180 assert_eq!(room, "namespace:room");
1181 assert_eq!(seq, 7);
1182 }
1183
1184 #[test]
1185 fn parse_message_id_no_colon_errors() {
1186 assert!(parse_message_id("nocolon").is_err());
1187 }
1188
1189 #[test]
1190 fn parse_message_id_invalid_seq_errors() {
1191 assert!(parse_message_id("room:notanumber").is_err());
1192 }
1193
1194 #[test]
1195 fn parse_message_id_negative_seq_errors() {
1196 assert!(parse_message_id("room:-1").is_err());
1198 }
1199
1200 #[test]
1201 fn parse_message_id_empty_room_ok() {
1202 let (room, seq) = parse_message_id(":5").unwrap();
1204 assert_eq!(room, "");
1205 assert_eq!(seq, 5);
1206 }
1207
1208 #[test]
1211 fn subscription_tier_serde_round_trip() {
1212 for tier in [
1213 SubscriptionTier::Full,
1214 SubscriptionTier::MentionsOnly,
1215 SubscriptionTier::Unsubscribed,
1216 ] {
1217 let json = serde_json::to_string(&tier).unwrap();
1218 let back: SubscriptionTier = serde_json::from_str(&json).unwrap();
1219 assert_eq!(tier, back);
1220 }
1221 }
1222
1223 #[test]
1224 fn subscription_tier_serde_snake_case() {
1225 assert_eq!(
1226 serde_json::to_string(&SubscriptionTier::Full).unwrap(),
1227 r#""full""#
1228 );
1229 assert_eq!(
1230 serde_json::to_string(&SubscriptionTier::MentionsOnly).unwrap(),
1231 r#""mentions_only""#
1232 );
1233 assert_eq!(
1234 serde_json::to_string(&SubscriptionTier::Unsubscribed).unwrap(),
1235 r#""unsubscribed""#
1236 );
1237 }
1238
1239 #[test]
1240 fn subscription_tier_display() {
1241 assert_eq!(SubscriptionTier::Full.to_string(), "full");
1242 assert_eq!(SubscriptionTier::MentionsOnly.to_string(), "mentions_only");
1243 assert_eq!(SubscriptionTier::Unsubscribed.to_string(), "unsubscribed");
1244 }
1245
1246 #[test]
1247 fn subscription_tier_from_str_canonical() {
1248 assert_eq!(
1249 "full".parse::<SubscriptionTier>().unwrap(),
1250 SubscriptionTier::Full
1251 );
1252 assert_eq!(
1253 "mentions_only".parse::<SubscriptionTier>().unwrap(),
1254 SubscriptionTier::MentionsOnly
1255 );
1256 assert_eq!(
1257 "unsubscribed".parse::<SubscriptionTier>().unwrap(),
1258 SubscriptionTier::Unsubscribed
1259 );
1260 }
1261
1262 #[test]
1263 fn subscription_tier_from_str_aliases() {
1264 assert_eq!(
1265 "mentions-only".parse::<SubscriptionTier>().unwrap(),
1266 SubscriptionTier::MentionsOnly
1267 );
1268 assert_eq!(
1269 "mentions".parse::<SubscriptionTier>().unwrap(),
1270 SubscriptionTier::MentionsOnly
1271 );
1272 assert_eq!(
1273 "none".parse::<SubscriptionTier>().unwrap(),
1274 SubscriptionTier::Unsubscribed
1275 );
1276 }
1277
1278 #[test]
1279 fn subscription_tier_from_str_invalid() {
1280 let err = "banana".parse::<SubscriptionTier>().unwrap_err();
1281 assert!(err.contains("unknown subscription tier"));
1282 assert!(err.contains("banana"));
1283 }
1284
1285 #[test]
1286 fn subscription_tier_display_round_trips_through_from_str() {
1287 for tier in [
1288 SubscriptionTier::Full,
1289 SubscriptionTier::MentionsOnly,
1290 SubscriptionTier::Unsubscribed,
1291 ] {
1292 let s = tier.to_string();
1293 let back: SubscriptionTier = s.parse().unwrap();
1294 assert_eq!(tier, back);
1295 }
1296 }
1297
1298 #[test]
1299 fn subscription_tier_is_copy() {
1300 let tier = SubscriptionTier::Full;
1301 let copy = tier;
1302 assert_eq!(tier, copy); }
1304}