1use chrono::{DateTime, Utc};
9use regex::Regex;
10use room_protocol::Message;
11
12#[derive(Debug, Clone, Default)]
18pub struct QueryFilter {
19 pub rooms: Vec<String>,
21 pub users: Vec<String>,
23 pub content_search: Option<String>,
26 pub content_regex: Option<Regex>,
31 pub after_seq: Option<(String, u64)>,
35 pub before_seq: Option<(String, u64)>,
38 pub after_ts: Option<DateTime<Utc>>,
40 pub before_ts: Option<DateTime<Utc>>,
42 pub mention_user: Option<String>,
44 pub public_only: bool,
46 pub target_id: Option<(String, u64)>,
51 pub limit: Option<usize>,
53 pub ascending: bool,
56}
57
58impl QueryFilter {
59 pub fn matches(&self, msg: &Message, room_id: &str) -> bool {
64 if !self.rooms.is_empty() && !self.rooms.iter().any(|r| r == room_id) {
66 return false;
67 }
68
69 if !self.users.is_empty() && !self.users.iter().any(|u| u == msg.user()) {
71 return false;
72 }
73
74 if self.public_only {
76 if let Message::DirectMessage { .. } = msg {
77 return false;
78 }
79 }
80
81 if let Some(ref needle) = self.content_search {
83 match msg.content() {
84 Some(content) if content.contains(needle.as_str()) => {}
85 _ => return false,
86 }
87 }
88
89 if let Some(ref re) = self.content_regex {
91 match msg.content() {
92 Some(content) if re.is_match(content) => {}
93 _ => return false,
94 }
95 }
96
97 if let Some(ref user) = self.mention_user {
99 if !msg.mentions().contains(user) {
100 return false;
101 }
102 }
103
104 if let Some((ref target_room, target_seq)) = self.target_id {
106 if room_id != target_room {
107 return false;
108 }
109 match msg.seq() {
110 Some(seq) if seq == target_seq => {}
111 _ => return false,
112 }
113 return true;
115 }
116
117 if let Some((ref filter_room, filter_seq)) = self.after_seq {
120 if room_id == filter_room {
121 match msg.seq() {
122 Some(seq) if seq > filter_seq => {}
123 _ => return false,
124 }
125 }
126 }
127
128 if let Some((ref filter_room, filter_seq)) = self.before_seq {
129 if room_id == filter_room {
130 match msg.seq() {
131 Some(seq) if seq < filter_seq => {}
132 _ => return false,
133 }
134 }
135 }
136
137 if let Some(after) = self.after_ts {
139 if msg.ts() <= &after {
140 return false;
141 }
142 }
143
144 if let Some(before) = self.before_ts {
145 if msg.ts() >= &before {
146 return false;
147 }
148 }
149
150 true
151 }
152}
153
154pub fn has_narrowing_filter(filter: &QueryFilter, new_or_wait: bool) -> bool {
161 new_or_wait
162 || !filter.rooms.is_empty()
163 || !filter.users.is_empty()
164 || filter.content_search.is_some()
165 || filter.content_regex.is_some()
166 || filter.after_seq.is_some()
167 || filter.before_seq.is_some()
168 || filter.after_ts.is_some()
169 || filter.before_ts.is_some()
170 || filter.mention_user.is_some()
171 || filter.target_id.is_some()
172 || filter.limit.is_some()
173}
174
175#[cfg(test)]
178mod tests {
179 use super::*;
180 use chrono::TimeZone;
181 use room_protocol::{make_dm, make_join, make_message};
182
183 fn ts(year: i32, month: u32, day: u32, h: u32, m: u32, s: u32) -> DateTime<Utc> {
184 Utc.with_ymd_and_hms(year, month, day, h, m, s).unwrap()
185 }
186
187 fn msg_with_seq(room: &str, user: &str, content: &str, seq: u64) -> Message {
188 let mut m = make_message(room, user, content);
189 m.set_seq(seq);
190 m
191 }
192
193 fn msg_with_ts(room: &str, user: &str, content: &str, t: DateTime<Utc>) -> Message {
194 match make_message(room, user, content) {
195 Message::Message {
196 id,
197 room,
198 user,
199 content,
200 seq,
201 ..
202 } => Message::Message {
203 id,
204 room,
205 user,
206 ts: t,
207 content,
208 seq,
209 },
210 other => other,
211 }
212 }
213
214 #[test]
217 fn default_filter_passes_message() {
218 let f = QueryFilter::default();
219 let msg = make_message("r", "alice", "hello");
220 assert!(f.matches(&msg, "r"));
221 }
222
223 #[test]
224 fn default_filter_passes_join() {
225 let f = QueryFilter::default();
226 let msg = make_join("r", "alice");
227 assert!(f.matches(&msg, "r"));
228 }
229
230 #[test]
231 fn default_filter_passes_dm() {
232 let f = QueryFilter::default();
233 let msg = make_dm("r", "alice", "bob", "secret");
234 assert!(f.matches(&msg, "r"));
235 }
236
237 #[test]
240 fn rooms_filter_passes_matching_room() {
241 let f = QueryFilter {
242 rooms: vec!["dev".into()],
243 ..Default::default()
244 };
245 let msg = make_message("dev", "alice", "hi");
246 assert!(f.matches(&msg, "dev"));
247 }
248
249 #[test]
250 fn rooms_filter_rejects_other_room() {
251 let f = QueryFilter {
252 rooms: vec!["dev".into()],
253 ..Default::default()
254 };
255 let msg = make_message("prod", "alice", "hi");
256 assert!(!f.matches(&msg, "prod"));
257 }
258
259 #[test]
260 fn rooms_filter_multiple_rooms_passes_any() {
261 let f = QueryFilter {
262 rooms: vec!["dev".into(), "staging".into()],
263 ..Default::default()
264 };
265 assert!(f.matches(&make_message("dev", "u", "x"), "dev"));
266 assert!(f.matches(&make_message("staging", "u", "x"), "staging"));
267 assert!(!f.matches(&make_message("prod", "u", "x"), "prod"));
268 }
269
270 #[test]
271 fn rooms_filter_empty_passes_all() {
272 let f = QueryFilter::default();
273 assert!(f.matches(&make_message("anywhere", "u", "x"), "anywhere"));
274 }
275
276 #[test]
279 fn users_filter_passes_matching_user() {
280 let f = QueryFilter {
281 users: vec!["alice".into()],
282 ..Default::default()
283 };
284 assert!(f.matches(&make_message("r", "alice", "hi"), "r"));
285 }
286
287 #[test]
288 fn users_filter_rejects_other_user() {
289 let f = QueryFilter {
290 users: vec!["alice".into()],
291 ..Default::default()
292 };
293 assert!(!f.matches(&make_message("r", "bob", "hi"), "r"));
294 }
295
296 #[test]
297 fn users_filter_multiple_users() {
298 let f = QueryFilter {
299 users: vec!["alice".into(), "carol".into()],
300 ..Default::default()
301 };
302 assert!(f.matches(&make_message("r", "alice", "x"), "r"));
303 assert!(f.matches(&make_message("r", "carol", "x"), "r"));
304 assert!(!f.matches(&make_message("r", "bob", "x"), "r"));
305 }
306
307 #[test]
310 fn public_only_excludes_dm() {
311 let f = QueryFilter {
312 public_only: true,
313 ..Default::default()
314 };
315 let msg = make_dm("r", "alice", "bob", "secret");
316 assert!(!f.matches(&msg, "r"));
317 }
318
319 #[test]
320 fn public_only_passes_regular_message() {
321 let f = QueryFilter {
322 public_only: true,
323 ..Default::default()
324 };
325 assert!(f.matches(&make_message("r", "alice", "hi"), "r"));
326 }
327
328 #[test]
329 fn public_only_false_passes_dm() {
330 let f = QueryFilter {
331 public_only: false,
332 ..Default::default()
333 };
334 let msg = make_dm("r", "alice", "bob", "secret");
335 assert!(f.matches(&msg, "r"));
336 }
337
338 #[test]
341 fn content_search_passes_when_contained() {
342 let f = QueryFilter {
343 content_search: Some("hello".into()),
344 ..Default::default()
345 };
346 assert!(f.matches(&make_message("r", "u", "say hello there"), "r"));
347 }
348
349 #[test]
350 fn content_search_rejects_when_absent() {
351 let f = QueryFilter {
352 content_search: Some("hello".into()),
353 ..Default::default()
354 };
355 assert!(!f.matches(&make_message("r", "u", "goodbye"), "r"));
356 }
357
358 #[test]
359 fn content_search_rejects_join_no_content() {
360 let f = QueryFilter {
361 content_search: Some("hello".into()),
362 ..Default::default()
363 };
364 assert!(!f.matches(&make_join("r", "alice"), "r"));
365 }
366
367 #[test]
368 fn content_search_is_case_sensitive() {
369 let f = QueryFilter {
370 content_search: Some("Hello".into()),
371 ..Default::default()
372 };
373 assert!(!f.matches(&make_message("r", "u", "hello"), "r"));
374 assert!(f.matches(&make_message("r", "u", "say Hello world"), "r"));
375 }
376
377 #[test]
380 fn content_regex_passes_matching_pattern() {
381 let f = QueryFilter {
382 content_regex: Some(Regex::new(r"\d+").unwrap()),
383 ..Default::default()
384 };
385 assert!(f.matches(&make_message("r", "u", "issue #42 fixed"), "r"));
386 }
387
388 #[test]
389 fn content_regex_rejects_non_matching() {
390 let f = QueryFilter {
391 content_regex: Some(Regex::new(r"^\d+$").unwrap()),
392 ..Default::default()
393 };
394 assert!(!f.matches(&make_message("r", "u", "no numbers here"), "r"));
395 }
396
397 #[test]
398 fn content_regex_invalid_pattern_rejected_at_compile_time() {
399 assert!(Regex::new("[invalid").is_err());
401 }
402
403 #[test]
404 fn content_regex_rejects_no_content() {
405 let f = QueryFilter {
406 content_regex: Some(Regex::new(".*").unwrap()),
407 ..Default::default()
408 };
409 assert!(!f.matches(&make_join("r", "alice"), "r"));
411 }
412
413 #[test]
416 fn mention_user_passes_when_mentioned() {
417 let f = QueryFilter {
418 mention_user: Some("bob".into()),
419 ..Default::default()
420 };
421 assert!(f.matches(&make_message("r", "alice", "hey @bob"), "r"));
422 }
423
424 #[test]
425 fn mention_user_rejects_when_not_mentioned() {
426 let f = QueryFilter {
427 mention_user: Some("bob".into()),
428 ..Default::default()
429 };
430 assert!(!f.matches(&make_message("r", "alice", "hey @carol"), "r"));
431 }
432
433 #[test]
434 fn mention_user_rejects_no_content() {
435 let f = QueryFilter {
436 mention_user: Some("bob".into()),
437 ..Default::default()
438 };
439 assert!(!f.matches(&make_join("r", "alice"), "r"));
440 }
441
442 #[test]
445 fn after_seq_passes_strictly_greater() {
446 let f = QueryFilter {
447 after_seq: Some(("r".into(), 10)),
448 ..Default::default()
449 };
450 assert!(f.matches(&msg_with_seq("r", "u", "x", 11), "r"));
451 }
452
453 #[test]
454 fn after_seq_rejects_equal() {
455 let f = QueryFilter {
456 after_seq: Some(("r".into(), 10)),
457 ..Default::default()
458 };
459 assert!(!f.matches(&msg_with_seq("r", "u", "x", 10), "r"));
460 }
461
462 #[test]
463 fn after_seq_rejects_lesser() {
464 let f = QueryFilter {
465 after_seq: Some(("r".into(), 10)),
466 ..Default::default()
467 };
468 assert!(!f.matches(&msg_with_seq("r", "u", "x", 5), "r"));
469 }
470
471 #[test]
472 fn after_seq_skips_constraint_for_different_room() {
473 let f = QueryFilter {
475 after_seq: Some(("dev".into(), 10)),
476 ..Default::default()
477 };
478 assert!(f.matches(&msg_with_seq("prod", "u", "x", 1), "prod"));
479 }
480
481 #[test]
482 fn after_seq_rejects_msg_with_no_seq() {
483 let f = QueryFilter {
484 after_seq: Some(("r".into(), 0)),
485 ..Default::default()
486 };
487 let msg = make_message("r", "u", "x");
489 assert!(!f.matches(&msg, "r"));
490 }
491
492 #[test]
495 fn before_seq_passes_strictly_lesser() {
496 let f = QueryFilter {
497 before_seq: Some(("r".into(), 10)),
498 ..Default::default()
499 };
500 assert!(f.matches(&msg_with_seq("r", "u", "x", 9), "r"));
501 }
502
503 #[test]
504 fn before_seq_rejects_equal() {
505 let f = QueryFilter {
506 before_seq: Some(("r".into(), 10)),
507 ..Default::default()
508 };
509 assert!(!f.matches(&msg_with_seq("r", "u", "x", 10), "r"));
510 }
511
512 #[test]
513 fn before_seq_skips_for_different_room() {
514 let f = QueryFilter {
515 before_seq: Some(("dev".into(), 5)),
516 ..Default::default()
517 };
518 assert!(f.matches(&msg_with_seq("prod", "u", "x", 100), "prod"));
519 }
520
521 #[test]
524 fn after_ts_passes_strictly_after() {
525 let cutoff = ts(2026, 3, 1, 12, 0, 0);
526 let f = QueryFilter {
527 after_ts: Some(cutoff),
528 ..Default::default()
529 };
530 let msg = msg_with_ts("r", "u", "x", ts(2026, 3, 1, 13, 0, 0));
531 assert!(f.matches(&msg, "r"));
532 }
533
534 #[test]
535 fn after_ts_rejects_equal() {
536 let cutoff = ts(2026, 3, 1, 12, 0, 0);
537 let f = QueryFilter {
538 after_ts: Some(cutoff),
539 ..Default::default()
540 };
541 let msg = msg_with_ts("r", "u", "x", cutoff);
542 assert!(!f.matches(&msg, "r"));
543 }
544
545 #[test]
546 fn after_ts_rejects_before() {
547 let cutoff = ts(2026, 3, 1, 12, 0, 0);
548 let f = QueryFilter {
549 after_ts: Some(cutoff),
550 ..Default::default()
551 };
552 let msg = msg_with_ts("r", "u", "x", ts(2026, 3, 1, 11, 0, 0));
553 assert!(!f.matches(&msg, "r"));
554 }
555
556 #[test]
557 fn before_ts_passes_strictly_before() {
558 let cutoff = ts(2026, 3, 1, 12, 0, 0);
559 let f = QueryFilter {
560 before_ts: Some(cutoff),
561 ..Default::default()
562 };
563 let msg = msg_with_ts("r", "u", "x", ts(2026, 3, 1, 11, 0, 0));
564 assert!(f.matches(&msg, "r"));
565 }
566
567 #[test]
568 fn before_ts_rejects_equal() {
569 let cutoff = ts(2026, 3, 1, 12, 0, 0);
570 let f = QueryFilter {
571 before_ts: Some(cutoff),
572 ..Default::default()
573 };
574 let msg = msg_with_ts("r", "u", "x", cutoff);
575 assert!(!f.matches(&msg, "r"));
576 }
577
578 #[test]
581 fn target_id_passes_exact_match() {
582 let f = QueryFilter {
583 target_id: Some(("r".into(), 7)),
584 ..Default::default()
585 };
586 assert!(f.matches(&msg_with_seq("r", "u", "x", 7), "r"));
587 }
588
589 #[test]
590 fn target_id_rejects_wrong_seq() {
591 let f = QueryFilter {
592 target_id: Some(("r".into(), 7)),
593 ..Default::default()
594 };
595 assert!(!f.matches(&msg_with_seq("r", "u", "x", 8), "r"));
596 assert!(!f.matches(&msg_with_seq("r", "u", "x", 6), "r"));
597 }
598
599 #[test]
600 fn target_id_rejects_wrong_room() {
601 let f = QueryFilter {
602 target_id: Some(("dev".into(), 7)),
603 ..Default::default()
604 };
605 assert!(!f.matches(&msg_with_seq("prod", "u", "x", 7), "prod"));
606 }
607
608 #[test]
609 fn target_id_rejects_no_seq() {
610 let f = QueryFilter {
611 target_id: Some(("r".into(), 1)),
612 ..Default::default()
613 };
614 let msg = make_message("r", "u", "no seq");
615 assert!(!f.matches(&msg, "r"));
616 }
617
618 #[test]
619 fn target_id_short_circuits_other_seq_filters() {
620 let f = QueryFilter {
622 target_id: Some(("r".into(), 7)),
623 after_seq: Some(("r".into(), 10)),
624 ..Default::default()
625 };
626 assert!(f.matches(&msg_with_seq("r", "u", "x", 7), "r"));
627 }
628
629 #[test]
632 fn has_narrowing_filter_empty_is_false() {
633 assert!(!has_narrowing_filter(&QueryFilter::default(), false));
634 }
635
636 #[test]
637 fn has_narrowing_filter_rooms_is_true() {
638 let f = QueryFilter {
639 rooms: vec!["r".into()],
640 ..Default::default()
641 };
642 assert!(has_narrowing_filter(&f, false));
643 }
644
645 #[test]
646 fn has_narrowing_filter_limit_is_true() {
647 let f = QueryFilter {
648 limit: Some(10),
649 ..Default::default()
650 };
651 assert!(has_narrowing_filter(&f, false));
652 }
653
654 #[test]
655 fn has_narrowing_filter_target_id_is_true() {
656 let f = QueryFilter {
657 target_id: Some(("r".into(), 1)),
658 ..Default::default()
659 };
660 assert!(has_narrowing_filter(&f, false));
661 }
662
663 #[test]
664 fn has_narrowing_filter_content_search_is_true() {
665 let f = QueryFilter {
666 content_search: Some("foo".into()),
667 ..Default::default()
668 };
669 assert!(has_narrowing_filter(&f, false));
670 }
671
672 #[test]
673 fn has_narrowing_filter_public_only_alone_is_false() {
674 let f = QueryFilter {
676 public_only: true,
677 ..Default::default()
678 };
679 assert!(!has_narrowing_filter(&f, false));
680 }
681
682 #[test]
683 fn has_narrowing_filter_new_or_wait_is_true() {
684 assert!(has_narrowing_filter(&QueryFilter::default(), true));
685 }
686
687 #[test]
688 fn has_narrowing_filter_content_regex_is_true() {
689 let f = QueryFilter {
690 content_regex: Some(Regex::new(r"\d+").unwrap()),
691 ..Default::default()
692 };
693 assert!(has_narrowing_filter(&f, false));
694 }
695
696 #[test]
699 fn combined_room_and_user_filter() {
700 let f = QueryFilter {
701 rooms: vec!["dev".into()],
702 users: vec!["alice".into()],
703 ..Default::default()
704 };
705 assert!(f.matches(&make_message("dev", "alice", "x"), "dev"));
706 assert!(!f.matches(&make_message("prod", "alice", "x"), "prod"));
708 assert!(!f.matches(&make_message("dev", "bob", "x"), "dev"));
710 }
711
712 #[test]
713 fn combined_content_and_mention() {
714 let f = QueryFilter {
715 content_search: Some("ticket".into()),
716 mention_user: Some("bob".into()),
717 ..Default::default()
718 };
719 assert!(f.matches(&make_message("r", "u", "ticket #1 assigned @bob"), "r"));
721 assert!(!f.matches(&make_message("r", "u", "ticket #1"), "r"));
723 assert!(!f.matches(&make_message("r", "u", "hey @bob"), "r"));
725 }
726
727 #[test]
728 fn combined_seq_range() {
729 let f = QueryFilter {
730 after_seq: Some(("r".into(), 5)),
731 before_seq: Some(("r".into(), 10)),
732 ..Default::default()
733 };
734 assert!(f.matches(&msg_with_seq("r", "u", "x", 7), "r"));
735 assert!(!f.matches(&msg_with_seq("r", "u", "x", 5), "r"));
736 assert!(!f.matches(&msg_with_seq("r", "u", "x", 10), "r"));
737 }
738}