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