1use alloc::collections::{BTreeMap, btree_map};
7use alloc::string::{String, ToString};
8use alloc::vec::Vec;
9use obby_proto::{CaseFolded, Casemapping, Prefix};
10
11pub const DEFAULT_RETENTION: usize = 5000;
17
18const MAX_WHOIS_RECORDS: usize = 64;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
32pub struct MessageKey {
33 #[cfg_attr(feature = "ts", ts(type = "number"))]
35 pub time_ms: u64,
36 #[cfg_attr(feature = "ts", ts(type = "number"))]
38 pub seq: u64,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
45#[cfg_attr(feature = "serde", serde(tag = "type", rename_all = "snake_case"))]
46#[non_exhaustive]
47pub enum MessageKind {
48 Privmsg,
50 Notice,
52 Ctcp {
54 command: String,
56 },
57 Tagmsg,
59 Join,
61 Part,
63 Quit,
65 Kick {
67 target: String,
69 },
70 Nick {
72 new_nick: String,
74 },
75 Topic,
77 Mode,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
84#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
85#[cfg_attr(feature = "ts", ts(rename = "ChatMessage"))]
86pub struct ChatMessage {
87 pub key: MessageKey,
89 pub msgid: Option<String>,
91 pub sender: String,
93 pub kind: MessageKind,
95 pub text: String,
97 pub account: Option<String>,
99 pub historical: bool,
101 pub own: bool,
103 pub reply_to: Option<String>,
108 pub reactions: BTreeMap<String, Vec<String>>,
110 #[cfg(feature = "obby")]
112 pub link_preview: Option<crate::extensions::LinkPreview>,
113 pub redacted: bool,
116}
117
118impl ChatMessage {
119 pub fn new(key: MessageKey, sender: impl Into<String>, kind: MessageKind) -> Self {
121 Self {
122 key,
123 msgid: None,
124 sender: sender.into(),
125 kind,
126 text: String::new(),
127 account: None,
128 historical: false,
129 own: false,
130 reply_to: None,
131 reactions: BTreeMap::new(),
132 #[cfg(feature = "obby")]
133 link_preview: None,
134 redacted: false,
135 }
136 }
137}
138
139#[derive(Debug, Clone)]
141#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
142#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
143#[cfg_attr(feature = "ts", ts(rename = "MessageLog"))]
144pub struct MessageLog {
145 #[cfg_attr(feature = "serde", serde(with = "messages_as_list"))]
146 #[cfg_attr(feature = "ts", ts(as = "Vec<ChatMessage>"))]
147 messages: BTreeMap<MessageKey, ChatMessage>,
148 by_msgid: BTreeMap<String, MessageKey>,
149 retention: usize,
150}
151
152#[cfg(feature = "serde")]
158mod messages_as_list {
159 use super::{BTreeMap, ChatMessage, MessageKey};
160 use alloc::vec::Vec;
161 use serde::{Deserialize, Deserializer, Serialize, Serializer};
162
163 pub(super) fn serialize<S: Serializer>(
164 messages: &BTreeMap<MessageKey, ChatMessage>,
165 serializer: S,
166 ) -> Result<S::Ok, S::Error> {
167 messages.values().collect::<Vec<_>>().serialize(serializer)
168 }
169
170 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
171 deserializer: D,
172 ) -> Result<BTreeMap<MessageKey, ChatMessage>, D::Error> {
173 Ok(Vec::<ChatMessage>::deserialize(deserializer)?
174 .into_iter()
175 .map(|message| (message.key, message))
176 .collect())
177 }
178}
179
180impl Default for MessageLog {
181 fn default() -> Self {
182 Self::with_retention(DEFAULT_RETENTION)
183 }
184}
185
186impl MessageLog {
187 pub fn with_retention(retention: usize) -> Self {
189 Self {
190 messages: BTreeMap::new(),
191 by_msgid: BTreeMap::new(),
192 retention: retention.max(1),
193 }
194 }
195
196 pub fn insert(&mut self, message: ChatMessage) -> bool {
202 if let Some(msgid) = &message.msgid {
203 if self.by_msgid.contains_key(msgid) {
204 return false;
205 }
206 } else if self.has_twin(&message) {
207 return false;
208 }
209 if let Some(msgid) = &message.msgid {
210 self.by_msgid.insert(msgid.clone(), message.key);
211 }
212 self.messages.insert(message.key, message);
213 self.trim();
214 true
215 }
216
217 fn has_twin(&self, candidate: &ChatMessage) -> bool {
218 let same_instant = MessageKey {
219 time_ms: candidate.key.time_ms,
220 seq: 0,
221 }..MessageKey {
222 time_ms: candidate.key.time_ms.saturating_add(1),
223 seq: 0,
224 };
225 self.messages.range(same_instant).any(|(_, held)| {
226 held.sender == candidate.sender
227 && held.text == candidate.text
228 && held.kind == candidate.kind
229 })
230 }
231
232 fn trim(&mut self) {
233 while self.messages.len() > self.retention {
234 let Some((_, dropped)) = self.messages.pop_first() else {
235 return;
236 };
237 if let Some(msgid) = dropped.msgid {
238 self.by_msgid.remove(&msgid);
239 }
240 }
241 }
242
243 pub fn contains(&self, msgid: &str) -> bool {
245 self.by_msgid.contains_key(msgid)
246 }
247
248 pub fn get(&self, msgid: &str) -> Option<&ChatMessage> {
250 self.messages.get(self.by_msgid.get(msgid)?)
251 }
252
253 pub fn get_mut(&mut self, msgid: &str) -> Option<&mut ChatMessage> {
255 let key = *self.by_msgid.get(msgid)?;
256 self.messages.get_mut(&key)
257 }
258
259 pub fn get_by_key(&self, key: &MessageKey) -> Option<&ChatMessage> {
264 self.messages.get(key)
265 }
266
267 pub fn iter(&self) -> btree_map::Values<'_, MessageKey, ChatMessage> {
269 self.messages.values()
270 }
271
272 pub fn last(&self) -> Option<&ChatMessage> {
274 self.messages.last_key_value().map(|(_, message)| message)
275 }
276
277 pub fn first(&self) -> Option<&ChatMessage> {
279 self.messages.first_key_value().map(|(_, message)| message)
280 }
281
282 pub fn len(&self) -> usize {
284 self.messages.len()
285 }
286
287 pub fn is_empty(&self) -> bool {
289 self.messages.is_empty()
290 }
291}
292
293impl<'a> IntoIterator for &'a MessageLog {
294 type Item = &'a ChatMessage;
295 type IntoIter = btree_map::Values<'a, MessageKey, ChatMessage>;
296
297 fn into_iter(self) -> Self::IntoIter {
298 self.iter()
299 }
300}
301
302#[derive(Debug, Clone, Default, PartialEq, Eq)]
308#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
309#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
310pub struct Membership {
311 pub prefixes: String,
313}
314
315impl Membership {
316 pub fn top_prefix(&self) -> Option<char> {
318 self.prefixes.chars().next()
319 }
320
321 pub fn grant(&mut self, prefix: char, order: &Prefix) {
323 if self.prefixes.contains(prefix) {
324 return;
325 }
326 self.prefixes.push(prefix);
327 let mut ranked: Vec<char> = self.prefixes.chars().collect();
328 ranked.sort_by_key(|c| order.rank(*c).unwrap_or(usize::MAX));
329 self.prefixes = ranked.into_iter().collect();
330 }
331
332 pub fn revoke(&mut self, prefix: char) {
334 self.prefixes.retain(|c| c != prefix);
335 }
336}
337
338#[derive(Debug, Clone, Default, PartialEq, Eq)]
340#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
341#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
342pub struct Person {
343 pub nick: String,
345 pub account: Option<String>,
347 pub away: Option<String>,
349 pub bot: bool,
351 pub username: Option<String>,
353 pub host: Option<String>,
355 pub realname: Option<String>,
357 pub operator: bool,
359 pub metadata: BTreeMap<String, String>,
364}
365
366#[derive(Debug, Clone, Default, PartialEq, Eq)]
372#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
373#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
374pub struct Whois {
375 pub nick: String,
377 pub username: Option<String>,
379 pub host: Option<String>,
381 pub realname: Option<String>,
383 pub server: Option<String>,
385 pub server_info: Option<String>,
387 pub operator: Option<String>,
389 #[cfg_attr(feature = "ts", ts(type = "number | null"))]
391 pub idle_secs: Option<u64>,
392 #[cfg_attr(feature = "ts", ts(type = "number | null"))]
394 pub signon_ms: Option<u64>,
395 pub channels: Vec<String>,
397 pub account: Option<String>,
399 pub actual_host: Option<String>,
401 pub secure: bool,
403 pub complete: bool,
405}
406
407#[derive(Debug, Clone, Default)]
409#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
410#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
411pub struct Channel {
412 pub name: String,
414 pub topic: Option<String>,
416 pub topic_by: Option<String>,
418 pub modes: BTreeMap<char, Option<String>>,
420 pub members: BTreeMap<CaseFolded, Membership>,
422 pub log: MessageLog,
424 pub unread: u32,
426 pub mentions: u32,
428 pub metadata: BTreeMap<String, String>,
433 pub read_marker: Option<String>,
435 pub typing: alloc::collections::BTreeSet<CaseFolded>,
437 pub named_modes: BTreeMap<String, Option<String>>,
443}
444
445#[derive(Debug, Clone, Default)]
447#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
448#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
449#[cfg_attr(feature = "ts", ts(rename = "Conversation"))]
450pub struct Conversation {
451 pub nick: String,
453 pub log: MessageLog,
455 pub unread: u32,
457 pub read_marker: Option<String>,
459 pub typing: alloc::collections::BTreeSet<CaseFolded>,
461}
462
463#[derive(Debug, Clone, Default)]
465#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
466#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
467#[cfg_attr(feature = "ts", ts(rename = "LocalUser"))]
468pub struct LocalUser {
469 pub nick: String,
471 pub account: Option<String>,
473 pub modes: String,
475 pub metadata: BTreeMap<String, String>,
480 pub away: Option<String>,
482}
483
484#[derive(Debug, Clone)]
486#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
487#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
488pub struct Model {
489 pub me: LocalUser,
491 channels: BTreeMap<CaseFolded, Channel>,
492 conversations: BTreeMap<CaseFolded, Conversation>,
493 people: BTreeMap<CaseFolded, Person>,
494 whois: BTreeMap<CaseFolded, Whois>,
495 retention: usize,
496 #[cfg_attr(feature = "ts", ts(type = "number"))]
497 next_seq: u64,
498}
499
500impl Default for Model {
501 fn default() -> Self {
502 Self::with_retention(DEFAULT_RETENTION)
503 }
504}
505
506impl Model {
507 pub fn with_retention(retention: usize) -> Self {
509 Self {
510 me: LocalUser::default(),
511 channels: BTreeMap::new(),
512 conversations: BTreeMap::new(),
513 people: BTreeMap::new(),
514 whois: BTreeMap::new(),
515 retention: retention.max(1),
516 next_seq: 0,
517 }
518 }
519
520 pub fn next_message_key(&mut self, time_ms: u64) -> MessageKey {
522 let seq = self.next_seq;
523 self.next_seq = self.next_seq.wrapping_add(1);
524 MessageKey { time_ms, seq }
525 }
526
527 pub fn channel(&self, key: &CaseFolded) -> Option<&Channel> {
529 self.channels.get(key)
530 }
531
532 pub fn channel_or_insert(&mut self, key: CaseFolded, name: &str) -> &mut Channel {
534 let retention = self.retention;
535 self.channels.entry(key).or_insert_with(|| Channel {
536 name: name.to_string(),
537 log: MessageLog::with_retention(retention),
538 ..Channel::default()
539 })
540 }
541
542 pub fn channels_mut(&mut self) -> btree_map::IterMut<'_, CaseFolded, Channel> {
544 self.channels.iter_mut()
545 }
546
547 pub fn channel_mut(&mut self, key: &CaseFolded) -> Option<&mut Channel> {
549 self.channels.get_mut(key)
550 }
551
552 pub fn remove_channel(&mut self, key: &CaseFolded) -> Option<Channel> {
554 self.channels.remove(key)
555 }
556
557 pub fn channels(&self) -> btree_map::Iter<'_, CaseFolded, Channel> {
559 self.channels.iter()
560 }
561
562 pub fn conversation(&self, key: &CaseFolded) -> Option<&Conversation> {
564 self.conversations.get(key)
565 }
566
567 pub fn conversation_or_insert(&mut self, key: CaseFolded, nick: &str) -> &mut Conversation {
569 let retention = self.retention;
570 self.conversations
571 .entry(key)
572 .or_insert_with(|| Conversation {
573 nick: nick.to_string(),
574 log: MessageLog::with_retention(retention),
575 ..Conversation::default()
576 })
577 }
578
579 pub fn set_typing(&mut self, target: &CaseFolded, who: CaseFolded, typing: bool) -> bool {
584 let set = match self.channels.get_mut(target) {
585 Some(channel) => &mut channel.typing,
586 None => match self.conversations.get_mut(target) {
587 Some(query) => &mut query.typing,
588 None => return false,
589 },
590 };
591 if typing {
592 set.insert(who)
593 } else {
594 set.remove(&who)
595 }
596 }
597
598 pub fn person(&self, key: &CaseFolded) -> Option<&Person> {
600 self.people.get(key)
601 }
602
603 pub fn person_or_insert(&mut self, key: CaseFolded, nick: &str) -> &mut Person {
605 self.people.entry(key).or_insert_with(|| Person {
606 nick: nick.to_string(),
607 ..Person::default()
608 })
609 }
610
611 pub fn person_mut(&mut self, key: &CaseFolded) -> Option<&mut Person> {
613 self.people.get_mut(key)
614 }
615
616 pub fn whois(&self, key: &CaseFolded) -> Option<&Whois> {
618 self.whois.get(key)
619 }
620
621 pub fn whois_or_insert(&mut self, key: CaseFolded, nick: &str) -> &mut Whois {
627 if self.whois.len() >= MAX_WHOIS_RECORDS && !self.whois.contains_key(&key) {
630 let stale = self
631 .whois
632 .iter()
633 .find(|(_, record)| record.complete)
634 .or_else(|| self.whois.iter().next())
635 .map(|(key, _)| key.clone());
636 if let Some(stale) = stale {
637 self.whois.remove(&stale);
638 }
639 }
640 let record = self.whois.entry(key).or_default();
641 if record.complete || record.nick.is_empty() {
642 *record = Whois {
643 nick: nick.to_string(),
644 ..Whois::default()
645 };
646 }
647 record
648 }
649
650 pub fn whois_records(&self) -> btree_map::Iter<'_, CaseFolded, Whois> {
652 self.whois.iter()
653 }
654
655 pub fn whois_mut(&mut self, key: &CaseFolded) -> Option<&mut Whois> {
657 self.whois.get_mut(key)
658 }
659
660 pub fn conversation_mut(&mut self, key: &CaseFolded) -> Option<&mut Conversation> {
662 self.conversations.get_mut(key)
663 }
664
665 pub fn conversations(&self) -> btree_map::Iter<'_, CaseFolded, Conversation> {
667 self.conversations.iter()
668 }
669
670 pub fn rename(&mut self, casemapping: Casemapping, from: &str, to: &str) {
676 let (old, new) = (casemapping.fold(from), casemapping.fold(to));
677 for channel in self.channels.values_mut() {
678 if let Some(membership) = channel.members.remove(&old) {
679 channel.members.insert(new.clone(), membership);
680 }
681 }
682 if let Some(mut query) = self.conversations.remove(&old) {
683 query.nick = to.to_string();
684 self.conversations.insert(new.clone(), query);
685 }
686 if let Some(mut person) = self.people.remove(&old) {
687 person.nick = to.to_string();
688 self.people.insert(new, person);
689 }
690 if casemapping.eq(&self.me.nick, from) {
691 self.me.nick = to.to_string();
692 }
693 }
694
695 pub fn rename_channel(&mut self, casemapping: Casemapping, from: &str, to: &str) -> bool {
700 let Some(mut channel) = self.channels.remove(&casemapping.fold(from)) else {
701 return false;
702 };
703 channel.name = to.to_string();
704 self.channels.insert(casemapping.fold(to), channel);
705 true
706 }
707
708 pub fn forget_strangers(&mut self) {
713 let mut keep: alloc::collections::BTreeSet<CaseFolded> =
714 self.conversations.keys().cloned().collect();
715 for channel in self.channels.values() {
716 keep.extend(channel.members.keys().cloned());
717 }
718 let me = self.me.nick.clone();
719 self.people
720 .retain(|key, person| keep.contains(key) || person.nick == me);
721 self.whois.retain(|key, _| keep.contains(key));
722 }
723
724 pub fn forget_whois(&mut self) {
729 self.whois.clear();
730 }
731
732 pub fn remove_everywhere(&mut self, who: &CaseFolded) -> Vec<CaseFolded> {
734 let mut left = Vec::new();
735 for (key, channel) in &mut self.channels {
736 if channel.members.remove(who).is_some() {
737 left.push(key.clone());
738 }
739 }
740 left
741 }
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747
748 fn message(seq: u64, time_ms: u64, sender: &str, text: &str) -> ChatMessage {
749 let mut message =
750 ChatMessage::new(MessageKey { time_ms, seq }, sender, MessageKind::Privmsg);
751 message.text = text.to_string();
752 message
753 }
754
755 fn with_id(mut message: ChatMessage, msgid: &str) -> ChatMessage {
756 message.msgid = Some(msgid.to_string());
757 message
758 }
759
760 #[test]
761 fn orders_by_timestamp_not_by_arrival() {
762 let mut log = MessageLog::default();
763 assert!(log.insert(message(0, 200, "a", "second")));
764 assert!(log.insert(message(1, 100, "a", "first")));
765 let texts: Vec<&str> = log.iter().map(|m| m.text.as_str()).collect();
766 assert_eq!(
767 texts,
768 ["first", "second"],
769 "a backfilled message sorts into place"
770 );
771 }
772
773 #[test]
774 fn breaks_a_timestamp_tie_by_arrival() {
775 let mut log = MessageLog::default();
776 log.insert(message(0, 100, "a", "one"));
777 log.insert(message(1, 100, "a", "two"));
778 log.insert(message(2, 100, "a", "three"));
779 let texts: Vec<&str> = log.iter().map(|m| m.text.as_str()).collect();
780 assert_eq!(texts, ["one", "two", "three"]);
781 }
782
783 #[test]
784 fn refuses_a_message_it_already_has_by_id() {
785 let mut log = MessageLog::default();
786 assert!(log.insert(with_id(message(0, 100, "a", "hi"), "x1")));
787 assert!(
788 !log.insert(with_id(message(1, 100, "a", "hi"), "x1")),
789 "a bouncer replaying the same msgid must not double up"
790 );
791 assert_eq!(log.len(), 1);
792 }
793
794 #[test]
795 fn refuses_an_identical_message_with_no_id() {
796 let mut log = MessageLog::default();
797 assert!(log.insert(message(0, 100, "a", "hi")));
798 assert!(!log.insert(message(1, 100, "a", "hi")));
799 assert_eq!(log.len(), 1);
800 }
801
802 #[test]
803 fn keeps_the_same_text_at_a_different_instant() {
804 let mut log = MessageLog::default();
805 assert!(log.insert(message(0, 100, "a", "hi")));
806 assert!(
807 log.insert(message(1, 101, "a", "hi")),
808 "saying it twice is not a duplicate"
809 );
810 assert_eq!(log.len(), 2);
811 }
812
813 #[test]
814 fn keeps_the_same_text_from_a_different_sender() {
815 let mut log = MessageLog::default();
816 assert!(log.insert(message(0, 100, "a", "hi")));
817 assert!(log.insert(message(1, 100, "b", "hi")));
818 assert_eq!(log.len(), 2);
819 }
820
821 #[test]
822 fn trims_the_oldest_once_it_is_full() {
823 let mut log = MessageLog::with_retention(3);
824 for i in 0..5 {
825 log.insert(message(i, 100 + i, "a", "x"));
826 }
827 assert_eq!(log.len(), 3);
828 assert_eq!(log.first().map(|m| m.key.time_ms), Some(102));
829 assert_eq!(log.last().map(|m| m.key.time_ms), Some(104));
830 }
831
832 #[test]
833 fn trimming_forgets_the_id_index_too() {
834 let mut log = MessageLog::with_retention(2);
835 log.insert(with_id(message(0, 100, "a", "x"), "old"));
836 log.insert(with_id(message(1, 101, "a", "x"), "mid"));
837 log.insert(with_id(message(2, 102, "a", "x"), "new"));
838 assert!(
839 !log.contains("old"),
840 "a dropped message must not linger in the index"
841 );
842 assert!(log.contains("new"));
843 assert_eq!(log.get("old"), None);
844 }
845
846 #[test]
847 fn the_cap_applies_to_backfill_the_same_way() {
848 let mut log = MessageLog::with_retention(2);
849 log.insert(message(0, 300, "a", "live"));
850 log.insert(message(1, 100, "a", "old"));
851 log.insert(message(2, 200, "a", "older"));
852 assert_eq!(log.len(), 2);
853 let texts: Vec<&str> = log.iter().map(|m| m.text.as_str()).collect();
854 assert_eq!(
855 texts,
856 ["older", "live"],
857 "the oldest goes, whichever way it arrived"
858 );
859 }
860
861 #[test]
862 fn finds_a_message_again_to_react_to_it() {
863 let mut log = MessageLog::default();
864 log.insert(with_id(message(0, 100, "a", "hi"), "x1"));
865 let found = log.get_mut("x1").expect("the message is there");
866 found
867 .reactions
868 .entry("👍".to_string())
869 .or_default()
870 .push("b".to_string());
871 assert_eq!(log.get("x1").map(|m| m.reactions.len()), Some(1));
872 }
873
874 #[test]
875 fn ranks_prefixes_in_the_servers_order_not_the_order_granted() {
876 let order = Prefix::parse("(qaohv)~&@%+").expect("valid prefix");
877 let mut member = Membership::default();
878 member.grant('+', &order);
879 member.grant('~', &order);
880 member.grant('@', &order);
881 assert_eq!(member.prefixes, "~@+");
882 assert_eq!(member.top_prefix(), Some('~'));
883 member.revoke('~');
884 assert_eq!(member.top_prefix(), Some('@'));
885 }
886
887 #[test]
888 fn granting_the_same_prefix_twice_changes_nothing() {
889 let order = Prefix::default();
890 let mut member = Membership::default();
891 member.grant('@', &order);
892 member.grant('@', &order);
893 assert_eq!(member.prefixes, "@");
894 }
895
896 #[test]
897 fn a_rename_moves_the_member_rather_than_leaving_a_twin() {
898 let map = Casemapping::Rfc1459;
899 let mut model = Model::default();
900 model
901 .channel_or_insert(map.fold("#obby"), "#obby")
902 .members
903 .insert(map.fold("[nick]"), Membership::default());
904 model.person_or_insert(map.fold("[nick]"), "[nick]");
905
906 model.rename(map, "[nick]", "{NICK}2");
907
908 let channel = model.channel(&map.fold("#obby")).expect("channel");
909 assert_eq!(
910 channel.members.len(),
911 1,
912 "folding {{}} onto [] must not leave two entries"
913 );
914 assert!(channel.members.contains_key(&map.fold("{nick}2")));
915 assert_eq!(
916 model.person(&map.fold("{nick}2")).map(|p| p.nick.as_str()),
917 Some("{NICK}2"),
918 "the person is renamed once, not once per channel"
919 );
920 }
921
922 #[test]
923 fn a_rename_of_ourselves_updates_who_we_are() {
924 let map = Casemapping::Rfc1459;
925 let mut model = Model::default();
926 model.me.nick = "me".to_string();
927 model.rename(map, "ME", "you");
928 assert_eq!(
929 model.me.nick, "you",
930 "the server may echo our nick in any case"
931 );
932 }
933
934 #[test]
935 fn a_quit_reports_every_channel_they_were_in() {
936 let map = Casemapping::Rfc1459;
937 let mut model = Model::default();
938 for name in ["#a", "#b", "#c"] {
939 model
940 .channel_or_insert(map.fold(name), name)
941 .members
942 .insert(map.fold("bob"), Membership::default());
943 }
944 model
945 .channel_or_insert(map.fold("#c"), "#c")
946 .members
947 .remove(&map.fold("bob"));
948
949 let left = model.remove_everywhere(&map.fold("bob"));
950 assert_eq!(left, [map.fold("#a"), map.fold("#b")]);
951 }
952
953 #[test]
954 fn retention_reaches_every_target_it_creates() {
955 let map = Casemapping::Rfc1459;
956 let mut model = Model::with_retention(2);
957 let channel = model.channel_or_insert(map.fold("#a"), "#a");
958 for i in 0..4 {
959 channel.log.insert(message(i, 100 + i, "a", "x"));
960 }
961 assert_eq!(channel.log.len(), 2);
962
963 let query = model.conversation_or_insert(map.fold("bob"), "bob");
964 for i in 0..4 {
965 query.log.insert(message(i, 100 + i, "bob", "x"));
966 }
967 assert_eq!(query.log.len(), 2);
968 }
969
970 #[test]
971 fn a_default_model_keeps_more_than_one_message() {
972 let map = Casemapping::Rfc1459;
973 let mut model = Model::default();
974 let channel = model.channel_or_insert(map.fold("#a"), "#a");
975 for i in 0..10 {
976 channel.log.insert(message(i, 100 + i, "a", "x"));
977 }
978 assert_eq!(channel.log.len(), 10);
979 }
980
981 #[test]
982 fn sequence_numbers_never_repeat() {
983 let mut model = Model::default();
984 let first = model.next_message_key(100);
985 let second = model.next_message_key(100);
986 assert!(second > first);
987 }
988}
989
990#[cfg(all(test, feature = "serde"))]
991mod serde_tests {
992 use super::*;
993
994 #[test]
995 fn a_log_survives_a_json_round_trip() {
996 let mut log = MessageLog::default();
997 for (seq, text) in ["first", "second"].into_iter().enumerate() {
998 let mut message = ChatMessage::new(
999 MessageKey {
1000 time_ms: 100 + seq as u64,
1001 seq: seq as u64,
1002 },
1003 "bob",
1004 MessageKind::Privmsg,
1005 );
1006 message.text = text.to_string();
1007 message.msgid = Some(alloc::format!("m{seq}"));
1008 log.insert(message);
1009 }
1010
1011 let json = serde_json::to_string(&log).expect("a log serialises");
1013 assert!(json.contains('['), "the messages are a list, not an object");
1014
1015 let restored: MessageLog = serde_json::from_str(&json).expect("and comes back");
1016 assert_eq!(restored.len(), 2);
1017 assert_eq!(restored.get("m1").map(|m| m.text.as_str()), Some("second"));
1018 let texts: Vec<&str> = restored.iter().map(|m| m.text.as_str()).collect();
1019 assert_eq!(texts, ["first", "second"], "and keeps its order");
1020 }
1021
1022 #[test]
1023 fn a_whole_model_serialises() {
1024 let map = Casemapping::Ascii;
1025 let mut model = Model::default();
1026 let mut message = ChatMessage::new(
1027 MessageKey { time_ms: 1, seq: 0 },
1028 "bob",
1029 MessageKind::Privmsg,
1030 );
1031 message.text = "hello".to_string();
1032 model
1033 .channel_or_insert(map.fold("#obby"), "#obby")
1034 .log
1035 .insert(message);
1036 serde_json::to_string(&model).expect("the model a host reads must serialise");
1037 }
1038}