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