bext-realtime 0.2.0

Realtime pub/sub for bext — WebSocket and SSE with optional Redis relay
Documentation
//! Topic matching with MQTT-style wildcards.
//!
//! - Exact: `system/deploy` matches only `system/deploy`
//! - Single-level wildcard `*`: `app/*/events` matches `app/marketing/events`
//!   but NOT `app/marketing/sub/events`
//! - Multi-level wildcard `#`: `app/#` matches `app/marketing/events`
//!   and `app/marketing/sub/events`

/// Stateless topic matcher — all methods are pure functions.
pub struct TopicMatcher;

impl TopicMatcher {
    /// Check whether a `pattern` matches a concrete `topic`.
    ///
    /// Segments are separated by `/`.
    ///
    /// Wildcards:
    /// - `*` matches exactly one segment
    /// - `#` matches zero or more trailing segments (must be last)
    pub fn matches(pattern: &str, topic: &str) -> bool {
        // Fast path: exact match
        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; // pattern index
        let mut ti = 0; // topic index

        while pi < pattern_segments.len() && ti < topic_segments.len() {
            let pat = pattern_segments[pi];
            let seg = topic_segments[ti];

            if pat == "#" {
                // Multi-level wildcard must be the last segment in the pattern.
                // It matches everything remaining.
                return pi == pattern_segments.len() - 1;
            } else if pat == "*" {
                // Single-level wildcard matches exactly one segment.
                pi += 1;
                ti += 1;
            } else if pat == seg {
                // Exact segment match.
                pi += 1;
                ti += 1;
            } else {
                return false;
            }
        }

        // If pattern ends with `#` at position pi, it can match zero remaining segments.
        if pi < pattern_segments.len() && pattern_segments[pi] == "#" {
            return pi == pattern_segments.len() - 1;
        }

        // Both must be fully consumed for a match.
        pi == pattern_segments.len() && ti == topic_segments.len()
    }
}

/// A set of topic patterns that a subscriber is interested in.
#[derive(Debug, Clone, Default)]
pub struct TopicFilter {
    patterns: Vec<String>,
}

impl TopicFilter {
    pub fn new() -> Self {
        Self {
            patterns: Vec::new(),
        }
    }

    /// Create a filter from a list of patterns.
    pub fn from_patterns(patterns: Vec<String>) -> Self {
        Self { patterns }
    }

    /// Add a pattern to the filter.
    pub fn add(&mut self, pattern: String) {
        if !self.patterns.contains(&pattern) {
            self.patterns.push(pattern);
        }
    }

    /// Remove a pattern from the filter.
    pub fn remove(&mut self, pattern: &str) {
        self.patterns.retain(|p| p != pattern);
    }

    /// Check if a topic matches any pattern in this filter.
    pub fn matches(&self, topic: &str) -> bool {
        self.patterns
            .iter()
            .any(|pat| TopicMatcher::matches(pat, topic))
    }

    /// Return the list of patterns.
    pub fn patterns(&self) -> &[String] {
        &self.patterns
    }

    /// Return the number of patterns.
    pub fn len(&self) -> usize {
        self.patterns.len()
    }

    /// Whether the filter has no patterns.
    pub fn is_empty(&self) -> bool {
        self.patterns.is_empty()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── Exact matching ──────────────────────────────────────────────

    #[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"));
    }

    // ── Single-level wildcard (*) ───────────────────────────────────

    #[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() {
        // "system/*" should not match "system" (no segment after slash)
        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"));
    }

    // ── Multi-level wildcard (#) ────────────────────────────────────

    #[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() {
        // "app/#" should match "app" (zero remaining segments after "app")
        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() {
        // "#/foo" is not a valid multi-level wildcard — we treat `#` literally
        // if it's not the last segment, matching degrades to exact on that segment.
        assert!(!TopicMatcher::matches("#/foo", "bar/foo"));
    }

    // ── Mixed wildcards ─────────────────────────────────────────────

    #[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"));
    }

    // ── TopicFilter ─────────────────────────────────────────────────

    #[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"]);
    }
}