Skip to main content

room_cli/
query.rs

1//! Query filter for the `room query` subcommand.
2//!
3//! [`QueryFilter`] determines whether a given message should be included in
4//! query output. All fields are optional — a missing field means "no constraint
5//! on that dimension". A message passes [`QueryFilter::matches`] only when
6//! every present constraint is satisfied (logical AND).
7
8use chrono::{DateTime, Utc};
9use regex::Regex;
10use room_protocol::Message;
11
12/// Filter criteria for the `room query` subcommand (née `room poll`).
13///
14/// Constructed by the CLI flag parser and evaluated per-message. The `limit`
15/// and `ascending` fields control result set size and ordering; they are
16/// applied externally by the caller after filtering, not inside `matches`.
17#[derive(Debug, Clone, Default)]
18pub struct QueryFilter {
19    /// Only include messages from these rooms. Empty = all rooms.
20    pub rooms: Vec<String>,
21    /// Only include messages sent by these users. Empty = all users.
22    pub users: Vec<String>,
23    /// Only include messages whose content contains this substring
24    /// (case-sensitive).
25    pub content_search: Option<String>,
26    /// Only include messages whose content matches this regex pattern.
27    ///
28    /// Stored as `String` to keep the struct `Clone`-able; compiled inside
29    /// [`matches`][Self::matches] on each call. An invalid pattern causes the
30    /// message to be excluded (treated as "no match").
31    pub content_regex: Option<String>,
32    /// Only include messages whose sequence number is strictly greater than
33    /// this value. Tuple is `(room_id, seq)`. The constraint is skipped for
34    /// messages whose `room_id` differs from the filter room.
35    pub after_seq: Option<(String, u64)>,
36    /// Only include messages whose sequence number is strictly less than this
37    /// value. Tuple is `(room_id, seq)`. Skipped for messages from other rooms.
38    pub before_seq: Option<(String, u64)>,
39    /// Only include messages with a timestamp strictly after this instant.
40    pub after_ts: Option<DateTime<Utc>>,
41    /// Only include messages with a timestamp strictly before this instant.
42    pub before_ts: Option<DateTime<Utc>>,
43    /// Only include messages that @mention this username.
44    pub mention_user: Option<String>,
45    /// Exclude `DirectMessage` variants (public-channel filter).
46    pub public_only: bool,
47    /// Only include the single message with this exact `(room_id, seq)`.
48    ///
49    /// When set, all other seq-based filters are ignored; the match is exact.
50    /// DM privacy is still enforced externally by the caller.
51    pub target_id: Option<(String, u64)>,
52    /// Maximum number of messages to return. Applied externally by the caller.
53    pub limit: Option<usize>,
54    /// If `true`, return messages oldest-first. If `false`, newest-first.
55    /// Applied externally by the caller.
56    pub ascending: bool,
57}
58
59impl QueryFilter {
60    /// Returns `true` if `msg` satisfies all constraints in this filter.
61    ///
62    /// `room_id` is the room in which `msg` arrived; it is used when comparing
63    /// against `after_seq`/`before_seq` (which carry their own room component).
64    pub fn matches(&self, msg: &Message, room_id: &str) -> bool {
65        // ── room filter ───────────────────────────────────────────────────────
66        if !self.rooms.is_empty() && !self.rooms.iter().any(|r| r == room_id) {
67            return false;
68        }
69
70        // ── user filter ───────────────────────────────────────────────────────
71        if !self.users.is_empty() && !self.users.iter().any(|u| u == msg.user()) {
72            return false;
73        }
74
75        // ── public_only: skip DirectMessage variants ──────────────────────────
76        if self.public_only {
77            if let Message::DirectMessage { .. } = msg {
78                return false;
79            }
80        }
81
82        // ── content_search: substring match ───────────────────────────────────
83        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        // ── content_regex: regex match ─────────────────────────────────────────
91        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        // ── mention filter ────────────────────────────────────────────────────
102        if let Some(ref user) = self.mention_user {
103            if !msg.mentions().contains(user) {
104                return false;
105            }
106        }
107
108        // ── target_id: exact (room, seq) match ───────────────────────────────
109        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            // When target_id is set, skip the range seq filters below.
118            return true;
119        }
120
121        // ── seq range filter ──────────────────────────────────────────────────
122        // Constraints only apply when the message's room matches the filter room.
123        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        // ── timestamp range filter ────────────────────────────────────────────
142        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
158/// Returns `true` if `filter` contains at least one narrowing criterion.
159///
160/// Used to validate that the `-p/--public` flag is not used alone. The
161/// narrowing criteria are: rooms, users, content_search, content_regex,
162/// after_seq, before_seq, after_ts, before_ts, mention_user, target_id,
163/// limit, or `--new`/`--wait` (passed via `new_or_wait`).
164pub 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// ── Tests ─────────────────────────────────────────────────────────────────────
180
181#[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    // ── default filter passes everything ─────────────────────────────────────
219
220    #[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    // ── rooms filter ──────────────────────────────────────────────────────────
242
243    #[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    // ── users filter ──────────────────────────────────────────────────────────
281
282    #[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    // ── public_only filter ────────────────────────────────────────────────────
312
313    #[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    // ── content_search filter ─────────────────────────────────────────────────
343
344    #[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    // ── content_regex filter ──────────────────────────────────────────────────
382
383    #[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        // Join has no content — should be excluded.
417        assert!(!f.matches(&make_join("r", "alice"), "r"));
418    }
419
420    // ── mention_user filter ────────────────────────────────────────────────────
421
422    #[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    // ── after_seq filter ──────────────────────────────────────────────────────
450
451    #[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        // Filter room is "dev", message is in "prod" — constraint does not apply.
481        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        // Message with no seq (None) fails the constraint.
495        let msg = make_message("r", "u", "x");
496        assert!(!f.matches(&msg, "r"));
497    }
498
499    // ── before_seq filter ─────────────────────────────────────────────────────
500
501    #[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    // ── after_ts / before_ts filters ─────────────────────────────────────────
529
530    #[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    // ── target_id filter ──────────────────────────────────────────────────────
586
587    #[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        // after_seq would reject seq=7, but target_id=7 should still pass.
628        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    // ── has_narrowing_filter ───────────────────────────────────────────────────
637
638    #[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        // public_only by itself is not a narrowing filter.
682        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    // ── combined filters ──────────────────────────────────────────────────────
704
705    #[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        // Wrong room.
714        assert!(!f.matches(&make_message("prod", "alice", "x"), "prod"));
715        // Wrong user.
716        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        // Both match.
727        assert!(f.matches(&make_message("r", "u", "ticket #1 assigned @bob"), "r"));
728        // Only content matches.
729        assert!(!f.matches(&make_message("r", "u", "ticket #1"), "r"));
730        // Only mention matches.
731        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}