mqtt_topic_engine/
topic_match.rs1#![allow(clippy::missing_docs_in_private_items)]
8
9use std::fmt;
10use std::ops::Range;
11use std::sync::Arc;
12
13use arcstr::{ArcStr, Substr};
14use smallvec::SmallVec;
15use thiserror::Error;
16
17#[derive(Debug, Clone)]
22pub struct TopicPath {
23 pub path: ArcStr,
25 pub segments: Vec<Substr>,
27}
28
29impl TopicPath {
30 pub fn new(path: impl Into<ArcStr>) -> Result<Self, TopicPathError> {
38 let path = path.into();
39 check_wellformed(&path)?;
40 let segments: Vec<Substr> =
41 path.split('/').map(|s| path.substr_from(s)).collect();
42 Ok(Self { path, segments })
43 }
44
45 pub fn path(&self) -> ArcStr {
47 self.path.clone()
48 }
49}
50
51impl fmt::Display for TopicPath {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 write!(f, "{}", self.path)
54 }
55}
56
57pub(crate) fn is_dollar_topic(first_segment: &str) -> bool {
63 first_segment.starts_with('$')
64}
65
66pub(crate) fn check_wellformed(topic: &str) -> Result<(), Malformed> {
74 if topic.is_empty() {
75 Err(Malformed::Empty)
76 } else if topic.contains('\0') {
77 Err(Malformed::NullChar)
78 } else {
79 Ok(())
80 }
81}
82
83pub(crate) enum Malformed {
88 Empty,
90 NullChar,
92}
93
94#[derive(Error, Debug, Clone, PartialEq, Eq)]
97pub enum TopicPathError {
98 #[error("Topic cannot be empty")]
100 Empty,
101
102 #[error("Topic must not contain the null character (U+0000)")]
104 NullChar,
105}
106
107impl From<Malformed> for TopicPathError {
108 fn from(malformed: Malformed) -> Self {
109 match malformed {
110 | Malformed::Empty => TopicPathError::Empty,
111 | Malformed::NullChar => TopicPathError::NullChar,
112 }
113 }
114}
115
116#[derive(Error, Debug, Clone, PartialEq, Eq)]
118pub enum TopicMatchError {
119 #[error("Pattern ended unexpectedly while matching topic")]
121 UnexpectedEndOfPattern,
122
123 #[error("Topic ended unexpectedly while matching pattern")]
125 UnexpectedEndOfTopic,
126
127 #[error("Hash wildcard (#) found in unexpected position")]
129 UnexpectedHashSegment,
130
131 #[error(
133 "Segment mismatch at position {position}: expected '{expected}', \
134 found '{found}'"
135 )]
136 SegmentMismatch {
137 expected: String,
139 found: String,
141 position: usize,
143 },
144
145 #[error("Duplicate parameter name found in pattern")]
147 DuplicateParameterName,
148
149 #[error(transparent)]
151 InvalidTopic(#[from] TopicPathError),
152
153 #[error(
156 "Topic filter with a leading wildcard cannot match reserved $-topic \
157 '{topic}' (MQTT §4.7.2)"
158 )]
159 DollarTopicExclusion {
160 topic: String,
162 },
163}
164
165#[derive(Clone)]
174pub struct TopicMatch {
175 topic: Arc<TopicPath>,
176 params: SmallVec<[Range<usize>; 3]>,
177 named_params: SmallVec<[(Substr, Range<usize>); 3]>,
178}
179
180impl TopicMatch {
181 pub(crate) fn from_match_result(
182 topic: Arc<TopicPath>,
183 params: SmallVec<[Range<usize>; 3]>,
184 named_params: SmallVec<[(Substr, Range<usize>); 3]>,
185 ) -> Self {
186 Self {
187 topic,
188 params,
189 named_params,
190 }
191 }
192
193 pub fn path_segments(&self) -> &Vec<Substr> {
195 &self.topic.segments
196 }
197
198 fn get_param_range(&self, range: &Range<usize>) -> Substr {
199 if range.is_empty() {
200 self.topic.path.substr(0 .. 0)
201 } else if range.len() == 1 {
202 self.topic.segments[range.start].clone()
203 } else {
204 let start_segment = &self.topic.segments[range.start];
205 let end_segment = &self.topic.segments[range.end - 1];
206
207 let start_pos = start_segment.as_ptr() as usize
208 - self.topic.path.as_ptr() as usize;
209 let end_pos = end_segment.as_ptr() as usize
210 - self.topic.path.as_ptr() as usize
211 + end_segment.len();
212
213 self.topic.path.substr(start_pos .. end_pos)
214 }
215 }
216
217 pub fn get_param(&self, index: usize) -> Option<Substr> {
222 self.params
223 .get(index)
224 .map(|range| self.get_param_range(range))
225 }
226
227 pub fn get_named_param(&self, name: &str) -> Option<Substr> {
229 self.named_params
230 .iter()
231 .find(|(n, _)| n.as_str() == name)
232 .map(|(_, range)| self.get_param_range(range))
233
234 }
236
237 pub fn topic_path(&self) -> ArcStr {
239 self.topic.path.clone()
240 }
241}
242
243impl fmt::Debug for TopicMatch {
245 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246 write!(f, "TopicMatch {{ topic: {}, params: [", self.topic.path)?;
247 for (i, param) in self.params.iter().enumerate() {
248 if i > 0 {
249 write!(f, ", ")?;
250 }
251 write!(f, "{}", self.get_param_range(param))?;
252 }
253 write!(f, "]")?;
254
255 if !self.named_params.is_empty() {
256 write!(f, ", named_params: {{")?;
257 for (name, range) in &self.named_params {
258 write!(f, "{}: {}, ", name, self.get_param_range(range))?;
259 }
260 write!(f, "}}")?;
261 }
262
263 write!(f, " }}")
264 }
265}
266
267impl fmt::Display for TopicMatch {
268 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
269 write!(f, "Match({})", self.topic.path)?;
270
271 if !self.params.is_empty() {
272 write!(f, " with {} params", self.params.len())?;
273 }
274
275 Ok(())
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn rejects_empty_topic() {
285 assert_eq!(TopicPath::new("").unwrap_err(), TopicPathError::Empty);
286 }
287
288 #[test]
289 fn rejects_null_char() {
290 assert_eq!(
291 TopicPath::new("a/\0/b").unwrap_err(),
292 TopicPathError::NullChar
293 );
294 }
295
296 #[test]
297 fn accepts_utf8_spaces_and_control_chars() {
298 assert!(TopicPath::new("наприклад/日本語/😀").is_ok());
300 assert!(TopicPath::new("a/\u{1}/b").is_ok());
302 assert!(TopicPath::new(" ").is_ok());
304 }
305
306 #[test]
307 fn splits_into_segments() {
308 let topic = TopicPath::new("a/b/c").unwrap();
309 assert_eq!(topic.segments.len(), 3);
310 }
311}