Skip to main content

bext_realtime/
topic.rs

1//! Topic matching with MQTT-style wildcards.
2//!
3//! - Exact: `system/deploy` matches only `system/deploy`
4//! - Single-level wildcard `*`: `app/*/events` matches `app/marketing/events`
5//!   but NOT `app/marketing/sub/events`
6//! - Multi-level wildcard `#`: `app/#` matches `app/marketing/events`
7//!   and `app/marketing/sub/events`
8
9/// Stateless topic matcher — all methods are pure functions.
10pub struct TopicMatcher;
11
12impl TopicMatcher {
13    /// Check whether a `pattern` matches a concrete `topic`.
14    ///
15    /// Segments are separated by `/`.
16    ///
17    /// Wildcards:
18    /// - `*` matches exactly one segment
19    /// - `#` matches zero or more trailing segments (must be last)
20    pub fn matches(pattern: &str, topic: &str) -> bool {
21        // Fast path: exact match
22        if pattern == topic {
23            return true;
24        }
25
26        let pattern_segments: Vec<&str> = pattern.split('/').collect();
27        let topic_segments: Vec<&str> = topic.split('/').collect();
28
29        let mut pi = 0; // pattern index
30        let mut ti = 0; // topic index
31
32        while pi < pattern_segments.len() && ti < topic_segments.len() {
33            let pat = pattern_segments[pi];
34            let seg = topic_segments[ti];
35
36            if pat == "#" {
37                // Multi-level wildcard must be the last segment in the pattern.
38                // It matches everything remaining.
39                return pi == pattern_segments.len() - 1;
40            } else if pat == "*" {
41                // Single-level wildcard matches exactly one segment.
42                pi += 1;
43                ti += 1;
44            } else if pat == seg {
45                // Exact segment match.
46                pi += 1;
47                ti += 1;
48            } else {
49                return false;
50            }
51        }
52
53        // If pattern ends with `#` at position pi, it can match zero remaining segments.
54        if pi < pattern_segments.len() && pattern_segments[pi] == "#" {
55            return pi == pattern_segments.len() - 1;
56        }
57
58        // Both must be fully consumed for a match.
59        pi == pattern_segments.len() && ti == topic_segments.len()
60    }
61}
62
63/// A set of topic patterns that a subscriber is interested in.
64#[derive(Debug, Clone, Default)]
65pub struct TopicFilter {
66    patterns: Vec<String>,
67}
68
69impl TopicFilter {
70    pub fn new() -> Self {
71        Self {
72            patterns: Vec::new(),
73        }
74    }
75
76    /// Create a filter from a list of patterns.
77    pub fn from_patterns(patterns: Vec<String>) -> Self {
78        Self { patterns }
79    }
80
81    /// Add a pattern to the filter.
82    pub fn add(&mut self, pattern: String) {
83        if !self.patterns.contains(&pattern) {
84            self.patterns.push(pattern);
85        }
86    }
87
88    /// Remove a pattern from the filter.
89    pub fn remove(&mut self, pattern: &str) {
90        self.patterns.retain(|p| p != pattern);
91    }
92
93    /// Check if a topic matches any pattern in this filter.
94    pub fn matches(&self, topic: &str) -> bool {
95        self.patterns
96            .iter()
97            .any(|pat| TopicMatcher::matches(pat, topic))
98    }
99
100    /// Return the list of patterns.
101    pub fn patterns(&self) -> &[String] {
102        &self.patterns
103    }
104
105    /// Return the number of patterns.
106    pub fn len(&self) -> usize {
107        self.patterns.len()
108    }
109
110    /// Whether the filter has no patterns.
111    pub fn is_empty(&self) -> bool {
112        self.patterns.is_empty()
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    // ── Exact matching ──────────────────────────────────────────────
121
122    #[test]
123    fn exact_match_same_topic() {
124        assert!(TopicMatcher::matches("system/deploy", "system/deploy"));
125    }
126
127    #[test]
128    fn exact_match_different_topic() {
129        assert!(!TopicMatcher::matches("system/deploy", "system/restart"));
130    }
131
132    #[test]
133    fn exact_match_single_segment() {
134        assert!(TopicMatcher::matches("deploy", "deploy"));
135    }
136
137    #[test]
138    fn exact_match_empty_string() {
139        assert!(TopicMatcher::matches("", ""));
140    }
141
142    #[test]
143    fn exact_match_deep_path() {
144        assert!(TopicMatcher::matches("a/b/c/d/e", "a/b/c/d/e"));
145    }
146
147    #[test]
148    fn no_match_prefix() {
149        assert!(!TopicMatcher::matches("system", "system/deploy"));
150    }
151
152    #[test]
153    fn no_match_suffix() {
154        assert!(!TopicMatcher::matches("system/deploy", "system"));
155    }
156
157    // ── Single-level wildcard (*) ───────────────────────────────────
158
159    #[test]
160    fn star_matches_one_segment() {
161        assert!(TopicMatcher::matches(
162            "app/*/events",
163            "app/marketing/events"
164        ));
165    }
166
167    #[test]
168    fn star_does_not_match_two_segments() {
169        assert!(!TopicMatcher::matches(
170            "app/*/events",
171            "app/marketing/sub/events"
172        ));
173    }
174
175    #[test]
176    fn star_at_beginning() {
177        assert!(TopicMatcher::matches("*/deploy", "system/deploy"));
178    }
179
180    #[test]
181    fn star_at_end() {
182        assert!(TopicMatcher::matches("system/*", "system/deploy"));
183    }
184
185    #[test]
186    fn star_does_not_match_empty_segment_at_end() {
187        // "system/*" should not match "system" (no segment after slash)
188        assert!(!TopicMatcher::matches("system/*", "system"));
189    }
190
191    #[test]
192    fn multiple_stars() {
193        assert!(TopicMatcher::matches("*/*/events", "app/marketing/events"));
194    }
195
196    #[test]
197    fn multiple_stars_wrong_depth() {
198        assert!(!TopicMatcher::matches("*/*/events", "app/events"));
199    }
200
201    #[test]
202    fn star_only() {
203        assert!(TopicMatcher::matches("*", "anything"));
204    }
205
206    #[test]
207    fn star_only_does_not_match_nested() {
208        assert!(!TopicMatcher::matches("*", "a/b"));
209    }
210
211    // ── Multi-level wildcard (#) ────────────────────────────────────
212
213    #[test]
214    fn hash_matches_one_remaining_segment() {
215        assert!(TopicMatcher::matches("app/#", "app/marketing"));
216    }
217
218    #[test]
219    fn hash_matches_many_remaining_segments() {
220        assert!(TopicMatcher::matches("app/#", "app/marketing/sub/events"));
221    }
222
223    #[test]
224    fn hash_matches_zero_remaining_segments() {
225        // "app/#" should match "app" (zero remaining segments after "app")
226        assert!(TopicMatcher::matches("app/#", "app"));
227    }
228
229    #[test]
230    fn hash_at_root() {
231        assert!(TopicMatcher::matches("#", "anything/at/all"));
232    }
233
234    #[test]
235    fn hash_at_root_single() {
236        assert!(TopicMatcher::matches("#", "single"));
237    }
238
239    #[test]
240    fn hash_at_root_empty() {
241        assert!(TopicMatcher::matches("#", ""));
242    }
243
244    #[test]
245    fn hash_after_prefix() {
246        assert!(TopicMatcher::matches(
247            "system/deploy/#",
248            "system/deploy/v2/beta"
249        ));
250    }
251
252    #[test]
253    fn hash_must_be_last_segment() {
254        // "#/foo" is not a valid multi-level wildcard — we treat `#` literally
255        // if it's not the last segment, matching degrades to exact on that segment.
256        assert!(!TopicMatcher::matches("#/foo", "bar/foo"));
257    }
258
259    // ── Mixed wildcards ─────────────────────────────────────────────
260
261    #[test]
262    fn star_then_hash() {
263        assert!(TopicMatcher::matches("*/deploy/#", "system/deploy/v2/beta"));
264    }
265
266    #[test]
267    fn star_then_hash_minimal() {
268        assert!(TopicMatcher::matches("*/deploy/#", "system/deploy"));
269    }
270
271    // ── TopicFilter ─────────────────────────────────────────────────
272
273    #[test]
274    fn filter_matches_any_pattern() {
275        let filter = TopicFilter::from_patterns(vec![
276            "system/deploy".to_string(),
277            "app/*/events".to_string(),
278        ]);
279        assert!(filter.matches("system/deploy"));
280        assert!(filter.matches("app/marketing/events"));
281        assert!(!filter.matches("app/marketing/sub/events"));
282    }
283
284    #[test]
285    fn filter_empty_matches_nothing() {
286        let filter = TopicFilter::new();
287        assert!(!filter.matches("anything"));
288    }
289
290    #[test]
291    fn filter_add_and_remove() {
292        let mut filter = TopicFilter::new();
293        filter.add("app/#".to_string());
294        assert!(filter.matches("app/test"));
295        filter.remove("app/#");
296        assert!(!filter.matches("app/test"));
297    }
298
299    #[test]
300    fn filter_deduplicates_on_add() {
301        let mut filter = TopicFilter::new();
302        filter.add("app/#".to_string());
303        filter.add("app/#".to_string());
304        assert_eq!(filter.len(), 1);
305    }
306
307    #[test]
308    fn filter_len_and_is_empty() {
309        let mut filter = TopicFilter::new();
310        assert!(filter.is_empty());
311        assert_eq!(filter.len(), 0);
312
313        filter.add("x".to_string());
314        assert!(!filter.is_empty());
315        assert_eq!(filter.len(), 1);
316    }
317
318    #[test]
319    fn filter_patterns_accessor() {
320        let filter = TopicFilter::from_patterns(vec!["a".to_string(), "b".to_string()]);
321        assert_eq!(filter.patterns(), &["a", "b"]);
322    }
323}