Skip to main content

ralph_proto/
topic.rs

1//! Topic types for event routing.
2//!
3//! Topics are routing keys used to match events to subscribers.
4//! Supports glob-style patterns like `impl.*` to match `impl.done`.
5
6use serde::{Deserialize, Serialize};
7
8/// A topic for event routing.
9///
10/// Topics can be either concrete (e.g., `impl.done`) or patterns (e.g., `impl.*`).
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub struct Topic(String);
13
14impl Topic {
15    /// Creates a new topic from a string.
16    pub fn new(topic: impl Into<String>) -> Self {
17        Self(topic.into())
18    }
19
20    /// Returns the topic as a string slice.
21    pub fn as_str(&self) -> &str {
22        &self.0
23    }
24
25    /// Returns true if this is a global wildcard (`*`) that matches everything.
26    ///
27    /// Used for fallback routing - global wildcards have lower priority than
28    /// specific subscriptions.
29    pub fn is_global_wildcard(&self) -> bool {
30        self.0 == "*"
31    }
32
33    /// Checks if this topic pattern matches a given topic.
34    ///
35    /// Pattern rules:
36    /// - `*` matches any single segment (e.g., `impl.*` matches `impl.done`)
37    /// - Exact match for non-pattern topics
38    /// - A single `*` matches everything
39    pub fn matches(&self, topic: &Topic) -> bool {
40        self.matches_str(topic.as_str())
41    }
42
43    /// Checks if this topic pattern matches a given topic string.
44    ///
45    /// Zero-allocation variant of `matches()` for hot paths.
46    /// Avoids creating a temporary `Topic` wrapper.
47    pub fn matches_str(&self, target: &str) -> bool {
48        let pattern = &self.0;
49
50        // Single wildcard matches everything
51        if pattern == "*" {
52            return true;
53        }
54
55        // Exact match (most common case for non-wildcard patterns)
56        if pattern == target {
57            return true;
58        }
59
60        // Quick length check: if no wildcards and lengths differ, can't match
61        if !pattern.contains('*') {
62            return false;
63        }
64
65        // Glob pattern matching using iterators (no Vec allocation)
66        let mut pattern_parts = pattern.split('.');
67        let mut target_parts = target.split('.');
68
69        loop {
70            match (pattern_parts.next(), target_parts.next()) {
71                (Some(p), Some(t)) => {
72                    if p != "*" && p != t {
73                        return false;
74                    }
75                }
76                (None, None) => return true,
77                _ => return false, // Length mismatch
78            }
79        }
80    }
81}
82
83impl From<&str> for Topic {
84    fn from(s: &str) -> Self {
85        Self::new(s)
86    }
87}
88
89impl From<String> for Topic {
90    fn from(s: String) -> Self {
91        Self::new(s)
92    }
93}
94
95impl std::fmt::Display for Topic {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        write!(f, "{}", self.0)
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn test_exact_match() {
107        let pattern = Topic::new("impl.done");
108        let target = Topic::new("impl.done");
109        assert!(pattern.matches(&target));
110    }
111
112    #[test]
113    fn test_no_match() {
114        let pattern = Topic::new("impl.done");
115        let target = Topic::new("review.done");
116        assert!(!pattern.matches(&target));
117    }
118
119    #[test]
120    fn test_wildcard_suffix() {
121        let pattern = Topic::new("impl.*");
122        assert!(pattern.matches(&Topic::new("impl.done")));
123        assert!(pattern.matches(&Topic::new("impl.started")));
124        assert!(!pattern.matches(&Topic::new("review.done")));
125    }
126
127    #[test]
128    fn test_wildcard_prefix() {
129        let pattern = Topic::new("*.done");
130        assert!(pattern.matches(&Topic::new("impl.done")));
131        assert!(pattern.matches(&Topic::new("review.done")));
132        assert!(!pattern.matches(&Topic::new("impl.started")));
133    }
134
135    #[test]
136    fn test_global_wildcard() {
137        let pattern = Topic::new("*");
138        assert!(pattern.matches(&Topic::new("impl.done")));
139        assert!(pattern.matches(&Topic::new("anything")));
140    }
141
142    #[test]
143    fn test_length_mismatch() {
144        let pattern = Topic::new("impl.*");
145        assert!(!pattern.matches(&Topic::new("impl.sub.done")));
146    }
147}