1use alloc::collections::BTreeSet;
8use alloc::string::{String, ToString};
9use alloc::vec::Vec;
10use obby_proto::CaseFolded;
11
12#[derive(Debug, Clone, Default)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
16#[cfg_attr(feature = "ts", ts(rename = "WatchList"))]
17pub struct WatchList {
18 watching: BTreeSet<CaseFolded>,
19 online: BTreeSet<CaseFolded>,
20}
21
22impl WatchList {
23 pub fn new() -> Self {
25 Self::default()
26 }
27
28 pub fn watch(&mut self, folded: impl IntoIterator<Item = CaseFolded>) {
30 self.watching.extend(folded);
31 }
32
33 pub fn unwatch(&mut self, folded: &[CaseFolded]) {
35 for nick in folded {
36 self.watching.remove(nick);
37 self.online.remove(nick);
38 }
39 }
40
41 pub fn clear(&mut self) {
43 self.watching.clear();
44 self.online.clear();
45 }
46
47 pub fn mark_online(&mut self, folded: CaseFolded) {
49 self.watching.insert(folded.clone());
50 self.online.insert(folded);
51 }
52
53 pub fn mark_offline(&mut self, folded: &CaseFolded) {
55 self.online.remove(folded);
56 }
57
58 pub fn is_watching(&self, folded: &CaseFolded) -> bool {
60 self.watching.contains(folded)
61 }
62
63 pub fn is_online(&self, folded: &CaseFolded) -> bool {
65 self.online.contains(folded)
66 }
67
68 pub fn watched(&self) -> impl Iterator<Item = &CaseFolded> {
70 self.watching.iter()
71 }
72
73 pub fn len(&self) -> usize {
75 self.watching.len()
76 }
77
78 pub fn is_empty(&self) -> bool {
80 self.watching.is_empty()
81 }
82
83 pub fn forget_presence(&mut self) {
85 self.online.clear();
86 }
87}
88
89pub(crate) fn batched(targets: &[String], limit: usize) -> Vec<String> {
94 let per_request = limit.max(1);
95 targets
96 .chunks(per_request)
97 .map(|chunk| chunk.join(","))
98 .collect()
99}
100
101pub(crate) fn split_targets(list: &str) -> Vec<String> {
103 list.split(',')
104 .filter(|target| !target.is_empty())
105 .map(ToString::to_string)
106 .collect()
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use obby_proto::Casemapping;
113
114 fn fold(nick: &str) -> CaseFolded {
115 Casemapping::Rfc1459.fold(nick)
116 }
117
118 #[test]
119 fn watching_someone_says_nothing_about_whether_they_are_here() {
120 let mut monitor = WatchList::new();
121 monitor.watch([fold("alice")]);
122 assert!(monitor.is_watching(&fold("alice")));
123 assert!(
124 !monitor.is_online(&fold("alice")),
125 "we have not heard yet, and guessing online would show a false green dot"
126 );
127 }
128
129 #[test]
130 fn the_server_deciding_someone_is_online_also_means_we_watch_them() {
131 let mut monitor = WatchList::new();
132 monitor.mark_online(fold("alice"));
133 assert!(monitor.is_watching(&fold("alice")));
134 assert!(monitor.is_online(&fold("alice")));
135
136 monitor.mark_offline(&fold("alice"));
137 assert!(
138 monitor.is_watching(&fold("alice")),
139 "going offline is not the same as being dropped"
140 );
141 assert!(!monitor.is_online(&fold("alice")));
142 }
143
144 #[test]
145 fn dropping_someone_forgets_both_facts() {
146 let mut monitor = WatchList::new();
147 monitor.mark_online(fold("alice"));
148 monitor.unwatch(&[fold("alice")]);
149 assert!(!monitor.is_watching(&fold("alice")));
150 assert!(!monitor.is_online(&fold("alice")));
151 }
152
153 #[test]
154 fn folding_means_one_person_however_they_are_spelled() {
155 let mut monitor = WatchList::new();
156 monitor.mark_online(fold("[alice]"));
157 assert!(
158 monitor.is_online(&fold("{ALICE}")),
159 "rfc1459 folds braces onto brackets, so this is the same person"
160 );
161 assert_eq!(monitor.len(), 1);
162 }
163
164 #[test]
165 fn a_dropped_link_forgets_who_was_here_but_not_who_we_watch() {
166 let mut monitor = WatchList::new();
167 monitor.mark_online(fold("alice"));
168 monitor.forget_presence();
169 assert!(monitor.is_watching(&fold("alice")));
170 assert!(
171 !monitor.is_online(&fold("alice")),
172 "presence from the old link says nothing about the new one"
173 );
174 }
175
176 #[test]
177 fn a_long_list_is_split_to_stay_inside_the_servers_limit() {
178 let targets: Vec<String> = (0..7).map(|i| alloc::format!("nick{i}")).collect();
179 let requests = batched(&targets, 3);
180 assert_eq!(
181 requests,
182 [
183 "nick0,nick1,nick2".to_string(),
184 "nick3,nick4,nick5".to_string(),
185 "nick6".to_string()
186 ],
187 "one oversized request is refused whole, so it has to be several"
188 );
189 }
190
191 #[test]
192 fn a_limit_of_zero_still_makes_progress() {
193 let targets = alloc::vec!["alice".to_string()];
194 assert_eq!(batched(&targets, 0), ["alice".to_string()]);
195 }
196
197 #[test]
198 fn reading_a_target_list_ignores_empty_entries() {
199 assert_eq!(
200 split_targets("alice,,bob,"),
201 ["alice".to_string(), "bob".to_string()]
202 );
203 assert!(split_targets("").is_empty());
204 }
205}