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