pub struct TopicMatcher;
impl TopicMatcher {
pub fn matches(pattern: &str, topic: &str) -> bool {
if pattern == topic {
return true;
}
let pattern_segments: Vec<&str> = pattern.split('/').collect();
let topic_segments: Vec<&str> = topic.split('/').collect();
let mut pi = 0; let mut ti = 0;
while pi < pattern_segments.len() && ti < topic_segments.len() {
let pat = pattern_segments[pi];
let seg = topic_segments[ti];
if pat == "#" {
return pi == pattern_segments.len() - 1;
} else if pat == "*" {
pi += 1;
ti += 1;
} else if pat == seg {
pi += 1;
ti += 1;
} else {
return false;
}
}
if pi < pattern_segments.len() && pattern_segments[pi] == "#" {
return pi == pattern_segments.len() - 1;
}
pi == pattern_segments.len() && ti == topic_segments.len()
}
}
#[derive(Debug, Clone, Default)]
pub struct TopicFilter {
patterns: Vec<String>,
}
impl TopicFilter {
pub fn new() -> Self {
Self {
patterns: Vec::new(),
}
}
pub fn from_patterns(patterns: Vec<String>) -> Self {
Self { patterns }
}
pub fn add(&mut self, pattern: String) {
if !self.patterns.contains(&pattern) {
self.patterns.push(pattern);
}
}
pub fn remove(&mut self, pattern: &str) {
self.patterns.retain(|p| p != pattern);
}
pub fn matches(&self, topic: &str) -> bool {
self.patterns
.iter()
.any(|pat| TopicMatcher::matches(pat, topic))
}
pub fn patterns(&self) -> &[String] {
&self.patterns
}
pub fn len(&self) -> usize {
self.patterns.len()
}
pub fn is_empty(&self) -> bool {
self.patterns.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_match_same_topic() {
assert!(TopicMatcher::matches("system/deploy", "system/deploy"));
}
#[test]
fn exact_match_different_topic() {
assert!(!TopicMatcher::matches("system/deploy", "system/restart"));
}
#[test]
fn exact_match_single_segment() {
assert!(TopicMatcher::matches("deploy", "deploy"));
}
#[test]
fn exact_match_empty_string() {
assert!(TopicMatcher::matches("", ""));
}
#[test]
fn exact_match_deep_path() {
assert!(TopicMatcher::matches("a/b/c/d/e", "a/b/c/d/e"));
}
#[test]
fn no_match_prefix() {
assert!(!TopicMatcher::matches("system", "system/deploy"));
}
#[test]
fn no_match_suffix() {
assert!(!TopicMatcher::matches("system/deploy", "system"));
}
#[test]
fn star_matches_one_segment() {
assert!(TopicMatcher::matches(
"app/*/events",
"app/marketing/events"
));
}
#[test]
fn star_does_not_match_two_segments() {
assert!(!TopicMatcher::matches(
"app/*/events",
"app/marketing/sub/events"
));
}
#[test]
fn star_at_beginning() {
assert!(TopicMatcher::matches("*/deploy", "system/deploy"));
}
#[test]
fn star_at_end() {
assert!(TopicMatcher::matches("system/*", "system/deploy"));
}
#[test]
fn star_does_not_match_empty_segment_at_end() {
assert!(!TopicMatcher::matches("system/*", "system"));
}
#[test]
fn multiple_stars() {
assert!(TopicMatcher::matches("*/*/events", "app/marketing/events"));
}
#[test]
fn multiple_stars_wrong_depth() {
assert!(!TopicMatcher::matches("*/*/events", "app/events"));
}
#[test]
fn star_only() {
assert!(TopicMatcher::matches("*", "anything"));
}
#[test]
fn star_only_does_not_match_nested() {
assert!(!TopicMatcher::matches("*", "a/b"));
}
#[test]
fn hash_matches_one_remaining_segment() {
assert!(TopicMatcher::matches("app/#", "app/marketing"));
}
#[test]
fn hash_matches_many_remaining_segments() {
assert!(TopicMatcher::matches("app/#", "app/marketing/sub/events"));
}
#[test]
fn hash_matches_zero_remaining_segments() {
assert!(TopicMatcher::matches("app/#", "app"));
}
#[test]
fn hash_at_root() {
assert!(TopicMatcher::matches("#", "anything/at/all"));
}
#[test]
fn hash_at_root_single() {
assert!(TopicMatcher::matches("#", "single"));
}
#[test]
fn hash_at_root_empty() {
assert!(TopicMatcher::matches("#", ""));
}
#[test]
fn hash_after_prefix() {
assert!(TopicMatcher::matches(
"system/deploy/#",
"system/deploy/v2/beta"
));
}
#[test]
fn hash_must_be_last_segment() {
assert!(!TopicMatcher::matches("#/foo", "bar/foo"));
}
#[test]
fn star_then_hash() {
assert!(TopicMatcher::matches("*/deploy/#", "system/deploy/v2/beta"));
}
#[test]
fn star_then_hash_minimal() {
assert!(TopicMatcher::matches("*/deploy/#", "system/deploy"));
}
#[test]
fn filter_matches_any_pattern() {
let filter = TopicFilter::from_patterns(vec![
"system/deploy".to_string(),
"app/*/events".to_string(),
]);
assert!(filter.matches("system/deploy"));
assert!(filter.matches("app/marketing/events"));
assert!(!filter.matches("app/marketing/sub/events"));
}
#[test]
fn filter_empty_matches_nothing() {
let filter = TopicFilter::new();
assert!(!filter.matches("anything"));
}
#[test]
fn filter_add_and_remove() {
let mut filter = TopicFilter::new();
filter.add("app/#".to_string());
assert!(filter.matches("app/test"));
filter.remove("app/#");
assert!(!filter.matches("app/test"));
}
#[test]
fn filter_deduplicates_on_add() {
let mut filter = TopicFilter::new();
filter.add("app/#".to_string());
filter.add("app/#".to_string());
assert_eq!(filter.len(), 1);
}
#[test]
fn filter_len_and_is_empty() {
let mut filter = TopicFilter::new();
assert!(filter.is_empty());
assert_eq!(filter.len(), 0);
filter.add("x".to_string());
assert!(!filter.is_empty());
assert_eq!(filter.len(), 1);
}
#[test]
fn filter_patterns_accessor() {
let filter = TopicFilter::from_patterns(vec!["a".to_string(), "b".to_string()]);
assert_eq!(filter.patterns(), &["a", "b"]);
}
}