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 pre-compiled regex.
27    ///
28    /// Compiled once at construction time. Callers should validate the pattern
29    /// before building the filter (e.g. via [`Regex::new`]).
30    pub content_regex: Option<Regex>,
31    /// Only include messages whose sequence number is strictly greater than
32    /// this value. Tuple is `(room_id, seq)`. The constraint is skipped for
33    /// messages whose `room_id` differs from the filter room.
34    pub after_seq: Option<(String, u64)>,
35    /// Only include messages whose sequence number is strictly less than this
36    /// value. Tuple is `(room_id, seq)`. Skipped for messages from other rooms.
37    pub before_seq: Option<(String, u64)>,
38    /// Only include messages with a timestamp strictly after this instant.
39    pub after_ts: Option<DateTime<Utc>>,
40    /// Only include messages with a timestamp strictly before this instant.
41    pub before_ts: Option<DateTime<Utc>>,
42    /// Only include messages that @mention this username.
43    pub mention_user: Option<String>,
44    /// Exclude `DirectMessage` variants (public-channel filter).
45    pub public_only: bool,
46    /// Only include the single message with this exact `(room_id, seq)`.
47    ///
48    /// When set, all other seq-based filters are ignored; the match is exact.
49    /// DM privacy is still enforced externally by the caller.
50    pub target_id: Option<(String, u64)>,
51    /// Maximum number of messages to return. Applied externally by the caller.
52    pub limit: Option<usize>,
53    /// If `true`, return messages oldest-first. If `false`, newest-first.
54    /// Applied externally by the caller.
55    pub ascending: bool,
56}
57
58impl QueryFilter {
59    /// Returns `true` if `msg` satisfies all constraints in this filter.
60    ///
61    /// `room_id` is the room in which `msg` arrived; it is used when comparing
62    /// against `after_seq`/`before_seq` (which carry their own room component).
63    pub fn matches(&self, msg: &Message, room_id: &str) -> bool {
64        // ── room filter ───────────────────────────────────────────────────────
65        if !self.rooms.is_empty() && !self.rooms.iter().any(|r| r == room_id) {
66            return false;
67        }
68
69        // ── user filter ───────────────────────────────────────────────────────
70        if !self.users.is_empty() && !self.users.iter().any(|u| u == msg.user()) {
71            return false;
72        }
73
74        // ── public_only: skip DirectMessage variants ──────────────────────────
75        if self.public_only {
76            if let Message::DirectMessage { .. } = msg {
77                return false;
78            }
79        }
80
81        // ── content_search: substring match ───────────────────────────────────
82        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        // ── content_regex: regex match ─────────────────────────────────────────
90        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        // ── mention filter ────────────────────────────────────────────────────
98        if let Some(ref user) = self.mention_user {
99            if !msg.mentions().contains(user) {
100                return false;
101            }
102        }
103
104        // ── target_id: exact (room, seq) match ───────────────────────────────
105        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            // When target_id is set, skip the range seq filters below.
114            return true;
115        }
116
117        // ── seq range filter ──────────────────────────────────────────────────
118        // Constraints only apply when the message's room matches the filter room.
119        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        // ── timestamp range filter ────────────────────────────────────────────
138        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
154/// Returns `true` if `filter` contains at least one narrowing criterion.
155///
156/// Used to validate that the `-p/--public` flag is not used alone. The
157/// narrowing criteria are: rooms, users, content_search, content_regex,
158/// after_seq, before_seq, after_ts, before_ts, mention_user, target_id,
159/// limit, or `--new`/`--wait` (passed via `new_or_wait`).
160pub 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// ── Tests ─────────────────────────────────────────────────────────────────────
176
177#[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    // ── default filter passes everything ─────────────────────────────────────
215
216    #[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    // ── rooms filter ──────────────────────────────────────────────────────────
238
239    #[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    // ── users filter ──────────────────────────────────────────────────────────
277
278    #[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    // ── public_only filter ────────────────────────────────────────────────────
308
309    #[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    // ── content_search filter ─────────────────────────────────────────────────
339
340    #[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    // ── content_regex filter ──────────────────────────────────────────────────
378
379    #[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        // Invalid patterns are now caught at construction time, not inside matches().
400        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        // Join has no content — should be excluded.
410        assert!(!f.matches(&make_join("r", "alice"), "r"));
411    }
412
413    // ── mention_user filter ────────────────────────────────────────────────────
414
415    #[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    // ── after_seq filter ──────────────────────────────────────────────────────
443
444    #[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        // Filter room is "dev", message is in "prod" — constraint does not apply.
474        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        // Message with no seq (None) fails the constraint.
488        let msg = make_message("r", "u", "x");
489        assert!(!f.matches(&msg, "r"));
490    }
491
492    // ── before_seq filter ─────────────────────────────────────────────────────
493
494    #[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    // ── after_ts / before_ts filters ─────────────────────────────────────────
522
523    #[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    // ── target_id filter ──────────────────────────────────────────────────────
579
580    #[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        // after_seq would reject seq=7, but target_id=7 should still pass.
621        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    // ── has_narrowing_filter ───────────────────────────────────────────────────
630
631    #[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        // public_only by itself is not a narrowing filter.
675        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    // ── combined filters ──────────────────────────────────────────────────────
697
698    #[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        // Wrong room.
707        assert!(!f.matches(&make_message("prod", "alice", "x"), "prod"));
708        // Wrong user.
709        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        // Both match.
720        assert!(f.matches(&make_message("r", "u", "ticket #1 assigned @bob"), "r"));
721        // Only content matches.
722        assert!(!f.matches(&make_message("r", "u", "ticket #1"), "r"));
723        // Only mention matches.
724        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}