Skip to main content

mqtt_topic_engine/
topic_match.rs

1//! Matched-topic types.
2//!
3//! [`TopicPath`] is a concrete topic split into segments; [`TopicMatch`] is the
4//! result of matching such a topic against a pattern, exposing the captured
5//! positional and named parameters.
6
7#![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/// A concrete MQTT topic, split into its `/`-delimited segments.
18///
19/// The original topic string and the segment slices share the same backing
20/// [`ArcStr`] allocation, so cloning and slicing are cheap.
21#[derive(Debug, Clone)]
22pub struct TopicPath {
23	/// The full topic string.
24	pub path: ArcStr,
25	/// The topic split on `/`; each segment is a slice into [`path`](Self::path).
26	pub segments: Vec<Substr>,
27}
28
29impl TopicPath {
30	/// Builds a [`TopicPath`] by splitting `path` on `/` into segments.
31	///
32	/// Rejects a topic that is not well-formed per MQTT §4.7.3: an empty string,
33	/// or one containing the null character (U+0000). Every other UTF-8 topic —
34	/// including spaces and the discouraged-but-legal U+0001..U+001F control
35	/// range — is accepted. The 65535-byte length ceiling is left to the wire
36	/// codec, not enforced here.
37	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	/// Returns a cheap (refcounted) clone of the full topic string.
46	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
57/// MQTT §4.7.2: a topic filter whose first level is a wildcard (`+`/`#`) must
58/// not match a topic name whose first level begins with `$` (the reserved
59/// `$SYS`/`$share` space). The exclusion is first-level-only — `$` anywhere but
60/// the leading character is an ordinary literal — so this tests just the first
61/// segment. The one predicate is shared by both the trie and pattern matchers.
62pub(crate) fn is_dollar_topic(first_segment: &str) -> bool {
63	first_segment.starts_with('$')
64}
65
66/// The MQTT §4.7.3 well-formedness kernel shared by topic-name ([`TopicPath`])
67/// and topic-filter ([`TopicPatternPath`](crate::topic_pattern_path::TopicPatternPath))
68/// construction: a topic MUST be non-empty and MUST NOT contain the null
69/// character (U+0000). Every other UTF-8 topic is accepted — spaces and the
70/// discouraged-but-legal U+0001..U+001F control range included. Length is
71/// deliberately not bounded here: the 65535-byte ceiling is a wire-codec
72/// concern, not a routing one.
73pub(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
83/// A topic-string well-formedness violation, mapped by each constructor to its
84/// own public error type ([`TopicPathError`] / [`TopicPatternError`]).
85///
86/// [`TopicPatternError`]: crate::topic_pattern_item::TopicPatternError
87pub(crate) enum Malformed {
88	/// The topic string was empty.
89	Empty,
90	/// The topic string contained the null character (U+0000).
91	NullChar,
92}
93
94/// Errors returned when constructing a [`TopicPath`] from a topic string
95/// (MQTT §4.7.3 well-formedness).
96#[derive(Error, Debug, Clone, PartialEq, Eq)]
97pub enum TopicPathError {
98	/// The topic was empty; a topic name must have at least one character.
99	#[error("Topic cannot be empty")]
100	Empty,
101
102	/// The topic contained the null character (U+0000), which is forbidden.
103	#[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/// Errors returned when matching a topic against a pattern.
117#[derive(Error, Debug, Clone, PartialEq, Eq)]
118pub enum TopicMatchError {
119	/// Pattern ended unexpectedly while matching topic
120	#[error("Pattern ended unexpectedly while matching topic")]
121	UnexpectedEndOfPattern,
122
123	/// Topic ended unexpectedly while matching pattern
124	#[error("Topic ended unexpectedly while matching pattern")]
125	UnexpectedEndOfTopic,
126
127	/// Hash wildcard (#) found in unexpected position
128	#[error("Hash wildcard (#) found in unexpected position")]
129	UnexpectedHashSegment,
130
131	/// Segment mismatch during topic matching
132	#[error(
133		"Segment mismatch at position {position}: expected '{expected}', \
134		 found '{found}'"
135	)]
136	SegmentMismatch {
137		/// Expected segment value
138		expected: String,
139		/// Actually found segment value
140		found: String,
141		/// Position where mismatch occurred
142		position: usize,
143	},
144
145	/// Duplicate parameter name found in pattern
146	#[error("Duplicate parameter name found in pattern")]
147	DuplicateParameterName,
148
149	/// The topic string being matched was not well-formed (MQTT §4.7.3).
150	#[error(transparent)]
151	InvalidTopic(#[from] TopicPathError),
152
153	/// A topic filter with a leading `+`/`#` wildcard was matched against a
154	/// `$`-prefixed topic, which MQTT §4.7.2 forbids at the first level.
155	#[error(
156		"Topic filter with a leading wildcard cannot match reserved $-topic \
157		 '{topic}' (MQTT §4.7.2)"
158	)]
159	DollarTopicExclusion {
160		/// The `$`-prefixed topic that the leading wildcard was excluded from.
161		topic: String,
162	},
163}
164
165/// The result of matching a [`TopicPath`] against a pattern.
166///
167/// Holds the matched topic plus the ranges of segments captured by the
168/// pattern's wildcards, accessible by position ([`get_param`](Self::get_param))
169/// or by name ([`get_named_param`](Self::get_named_param)).
170///
171/// `Clone` is cheap: an `Arc` bump for the shared path plus two small inline
172/// vectors of segment ranges (no re-parsing, no string copies).
173#[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	/// Returns the matched topic's segments.
194	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	/// Returns the positional parameter captured at `index`, if any.
218	///
219	/// Parameters are numbered in pattern order; a `#` wildcard yields the
220	/// joined remainder of the topic.
221	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	/// Returns the value of the named parameter `name`, if the pattern bound one.
228	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		//self.named_params.get(name).map(|range| self.get_param_range(range))
235	}
236
237	/// Returns a cheap (refcounted) clone of the matched topic string.
238	pub fn topic_path(&self) -> ArcStr {
239		self.topic.path.clone()
240	}
241}
242
243//Implement Debug for TopicMatch, using get_param and get_named_param
244impl 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		// Must not regress to the old ASCII-only gate.
299		assert!(TopicPath::new("наприклад/日本語/😀").is_ok());
300		// U+0001 is discouraged but spec-legal (§4.7.3).
301		assert!(TopicPath::new("a/\u{1}/b").is_ok());
302		// A space is a valid topic.
303		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}