1pub struct TopicMatcher;
11
12impl TopicMatcher {
13 pub fn matches(pattern: &str, topic: &str) -> bool {
21 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; let mut ti = 0; 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 return pi == pattern_segments.len() - 1;
40 } else if pat == "*" {
41 pi += 1;
43 ti += 1;
44 } else if pat == seg {
45 pi += 1;
47 ti += 1;
48 } else {
49 return false;
50 }
51 }
52
53 if pi < pattern_segments.len() && pattern_segments[pi] == "#" {
55 return pi == pattern_segments.len() - 1;
56 }
57
58 pi == pattern_segments.len() && ti == topic_segments.len()
60 }
61}
62
63#[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 pub fn from_patterns(patterns: Vec<String>) -> Self {
78 Self { patterns }
79 }
80
81 pub fn add(&mut self, pattern: String) {
83 if !self.patterns.contains(&pattern) {
84 self.patterns.push(pattern);
85 }
86 }
87
88 pub fn remove(&mut self, pattern: &str) {
90 self.patterns.retain(|p| p != pattern);
91 }
92
93 pub fn matches(&self, topic: &str) -> bool {
95 self.patterns
96 .iter()
97 .any(|pat| TopicMatcher::matches(pat, topic))
98 }
99
100 pub fn patterns(&self) -> &[String] {
102 &self.patterns
103 }
104
105 pub fn len(&self) -> usize {
107 self.patterns.len()
108 }
109
110 pub fn is_empty(&self) -> bool {
112 self.patterns.is_empty()
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 #[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 #[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 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 #[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 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 assert!(!TopicMatcher::matches("#/foo", "bar/foo"));
257 }
258
259 #[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 #[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}