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