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