Skip to main content

topiq_core/
topic.rs

1use std::fmt;
2use std::sync::Arc;
3
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5
6use crate::error::TopiqError;
7
8/// A validated subject string used for topic-based pub/sub routing.
9///
10/// Subjects use `.` as a token separator. Wildcards are supported:
11/// - `*` matches exactly one token
12/// - `>` matches one or more tokens (must be last token)
13///
14/// Examples: `sensors.temp.room1`, `sensors.*.room1`, `sensors.>`
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct Subject(Arc<str>);
17
18impl Serialize for Subject {
19    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
20        self.0.serialize(serializer)
21    }
22}
23
24impl<'de> Deserialize<'de> for Subject {
25    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
26        let s = String::deserialize(deserializer)?;
27        Subject::new(&s).map_err(serde::de::Error::custom)
28    }
29}
30
31impl Subject {
32    pub fn new(raw: &str) -> crate::Result<Self> {
33        validate_subject(raw)?;
34        Ok(Self(Arc::from(raw)))
35    }
36
37    pub fn as_str(&self) -> &str {
38        &self.0
39    }
40
41    pub fn tokens(&self) -> impl Iterator<Item = &str> {
42        self.0.split('.')
43    }
44
45    pub fn is_wildcard(&self) -> bool {
46        self.0.contains('*') || self.0.contains('>')
47    }
48
49    pub fn token_count(&self) -> usize {
50        self.0.split('.').count()
51    }
52}
53
54impl fmt::Display for Subject {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        f.write_str(&self.0)
57    }
58}
59
60impl AsRef<str> for Subject {
61    fn as_ref(&self) -> &str {
62        &self.0
63    }
64}
65
66/// Maximum allowed subject length in bytes.
67const MAX_SUBJECT_LEN: usize = 256;
68
69fn validate_subject(raw: &str) -> crate::Result<()> {
70    if raw.is_empty() {
71        return Err(TopiqError::InvalidSubject {
72            reason: "subject cannot be empty".into(),
73        });
74    }
75
76    if raw.len() > MAX_SUBJECT_LEN {
77        return Err(TopiqError::InvalidSubject {
78            reason: format!(
79                "subject exceeds maximum length of {} bytes (got {})",
80                MAX_SUBJECT_LEN,
81                raw.len()
82            ),
83        });
84    }
85
86    if raw.starts_with('.') || raw.ends_with('.') {
87        return Err(TopiqError::InvalidSubject {
88            reason: "subject cannot start or end with '.'".into(),
89        });
90    }
91
92    if raw.contains("..") {
93        return Err(TopiqError::InvalidSubject {
94            reason: "subject cannot contain empty tokens (double dots)".into(),
95        });
96    }
97
98    let tokens: Vec<&str> = raw.split('.').collect();
99    for (i, token) in tokens.iter().enumerate() {
100        if token.is_empty() {
101            return Err(TopiqError::InvalidSubject {
102                reason: "subject contains an empty token".into(),
103            });
104        }
105
106        if *token == ">" && i != tokens.len() - 1 {
107            return Err(TopiqError::InvalidSubject {
108                reason: "'>' wildcard must be the last token".into(),
109            });
110        }
111
112        if (token.contains('*') || token.contains('>')) && token.len() > 1 {
113            return Err(TopiqError::InvalidSubject {
114                reason: format!("wildcard token '{}' must stand alone", token),
115            });
116        }
117    }
118
119    Ok(())
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn valid_simple_subject() {
128        assert!(Subject::new("sensors.temp.room1").is_ok());
129    }
130
131    #[test]
132    fn valid_single_token() {
133        assert!(Subject::new("hello").is_ok());
134    }
135
136    #[test]
137    fn valid_wildcard_star() {
138        assert!(Subject::new("sensors.*.room1").is_ok());
139    }
140
141    #[test]
142    fn valid_wildcard_gt() {
143        assert!(Subject::new("sensors.>").is_ok());
144    }
145
146    #[test]
147    fn valid_star_only() {
148        assert!(Subject::new("*").is_ok());
149    }
150
151    #[test]
152    fn valid_gt_only() {
153        assert!(Subject::new(">").is_ok());
154    }
155
156    #[test]
157    fn invalid_empty() {
158        assert!(Subject::new("").is_err());
159    }
160
161    #[test]
162    fn invalid_double_dot() {
163        assert!(Subject::new("sensors..temp").is_err());
164    }
165
166    #[test]
167    fn invalid_leading_dot() {
168        assert!(Subject::new(".sensors").is_err());
169    }
170
171    #[test]
172    fn invalid_trailing_dot() {
173        assert!(Subject::new("sensors.").is_err());
174    }
175
176    #[test]
177    fn invalid_gt_not_last() {
178        assert!(Subject::new("sensors.>.temp").is_err());
179    }
180
181    #[test]
182    fn invalid_mixed_wildcard() {
183        assert!(Subject::new("sensors.te*").is_err());
184    }
185
186    #[test]
187    fn tokens_returns_segments() {
188        let s = Subject::new("a.b.c").unwrap();
189        let tokens: Vec<&str> = s.tokens().collect();
190        assert_eq!(tokens, vec!["a", "b", "c"]);
191    }
192
193    #[test]
194    fn is_wildcard_detection() {
195        assert!(!Subject::new("a.b").unwrap().is_wildcard());
196        assert!(Subject::new("a.*").unwrap().is_wildcard());
197        assert!(Subject::new("a.>").unwrap().is_wildcard());
198    }
199
200    #[test]
201    fn display_roundtrip() {
202        let s = Subject::new("foo.bar").unwrap();
203        assert_eq!(s.to_string(), "foo.bar");
204    }
205
206    #[test]
207    fn serde_roundtrip() {
208        let s = Subject::new("a.b.c").unwrap();
209        let encoded = rmp_serde::to_vec(&s).unwrap();
210        let decoded: Subject = rmp_serde::from_slice(&encoded).unwrap();
211        assert_eq!(s, decoded);
212    }
213}