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	pub fn new(path: impl Into<ArcStr>) -> Self {
32		let path = path.into();
33		let segments: Vec<Substr> =
34			path.split('/').map(|s| path.substr_from(s)).collect();
35		Self { path, segments }
36	}
37
38	/// Returns a cheap (refcounted) clone of the full topic string.
39	pub fn path(&self) -> ArcStr {
40		self.path.clone()
41	}
42}
43
44impl fmt::Display for TopicPath {
45	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46		write!(f, "{}", self.path)
47	}
48}
49
50/// Errors returned when matching a topic against a pattern.
51#[derive(Error, Debug, Clone, PartialEq, Eq)]
52pub enum TopicMatchError {
53	/// Pattern ended unexpectedly while matching topic
54	#[error("Pattern ended unexpectedly while matching topic")]
55	UnexpectedEndOfPattern,
56
57	/// Topic ended unexpectedly while matching pattern
58	#[error("Topic ended unexpectedly while matching pattern")]
59	UnexpectedEndOfTopic,
60
61	/// Hash wildcard (#) found in unexpected position
62	#[error("Hash wildcard (#) found in unexpected position")]
63	UnexpectedHashSegment,
64
65	/// Segment mismatch during topic matching
66	#[error(
67		"Segment mismatch at position {position}: expected '{expected}', \
68		 found '{found}'"
69	)]
70	SegmentMismatch {
71		/// Expected segment value
72		expected: String,
73		/// Actually found segment value
74		found: String,
75		/// Position where mismatch occurred
76		position: usize,
77	},
78
79	/// Duplicate parameter name found in pattern
80	#[error("Duplicate parameter name found in pattern")]
81	DuplicateParameterName,
82}
83
84/// The result of matching a [`TopicPath`] against a pattern.
85///
86/// Holds the matched topic plus the ranges of segments captured by the
87/// pattern's wildcards, accessible by position ([`get_param`](Self::get_param))
88/// or by name ([`get_named_param`](Self::get_named_param)).
89///
90/// `Clone` is cheap: an `Arc` bump for the shared path plus two small inline
91/// vectors of segment ranges (no re-parsing, no string copies).
92#[derive(Clone)]
93pub struct TopicMatch {
94	topic: Arc<TopicPath>,
95	params: SmallVec<[Range<usize>; 3]>,
96	named_params: SmallVec<[(Substr, Range<usize>); 3]>,
97}
98
99impl TopicMatch {
100	pub(crate) fn from_match_result(
101		topic: Arc<TopicPath>,
102		params: SmallVec<[Range<usize>; 3]>,
103		named_params: SmallVec<[(Substr, Range<usize>); 3]>,
104	) -> Self {
105		Self {
106			topic,
107			params,
108			named_params,
109		}
110	}
111
112	/// Returns the matched topic's segments.
113	pub fn path_segments(&self) -> &Vec<Substr> {
114		&self.topic.segments
115	}
116
117	fn get_param_range(&self, range: &Range<usize>) -> Substr {
118		if range.is_empty() {
119			self.topic.path.substr(0 .. 0)
120		} else if range.len() == 1 {
121			self.topic.segments[range.start].clone()
122		} else {
123			let start_segment = &self.topic.segments[range.start];
124			let end_segment = &self.topic.segments[range.end - 1];
125
126			let start_pos = start_segment.as_ptr() as usize
127				- self.topic.path.as_ptr() as usize;
128			let end_pos = end_segment.as_ptr() as usize
129				- self.topic.path.as_ptr() as usize
130				+ end_segment.len();
131
132			self.topic.path.substr(start_pos .. end_pos)
133		}
134	}
135
136	/// Returns the positional parameter captured at `index`, if any.
137	///
138	/// Parameters are numbered in pattern order; a `#` wildcard yields the
139	/// joined remainder of the topic.
140	pub fn get_param(&self, index: usize) -> Option<Substr> {
141		self.params
142			.get(index)
143			.map(|range| self.get_param_range(range))
144	}
145
146	/// Returns the value of the named parameter `name`, if the pattern bound one.
147	pub fn get_named_param(&self, name: &str) -> Option<Substr> {
148		self.named_params
149			.iter()
150			.find(|(n, _)| n.as_str() == name)
151			.map(|(_, range)| self.get_param_range(range))
152
153		//self.named_params.get(name).map(|range| self.get_param_range(range))
154	}
155
156	/// Returns a cheap (refcounted) clone of the matched topic string.
157	pub fn topic_path(&self) -> ArcStr {
158		self.topic.path.clone()
159	}
160}
161
162//Implement Debug for TopicMatch, using get_param and get_named_param
163impl fmt::Debug for TopicMatch {
164	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165		write!(f, "TopicMatch {{ topic: {}, params: [", self.topic.path)?;
166		for (i, param) in self.params.iter().enumerate() {
167			if i > 0 {
168				write!(f, ", ")?;
169			}
170			write!(f, "{}", self.get_param_range(param))?;
171		}
172		write!(f, "]")?;
173
174		if !self.named_params.is_empty() {
175			write!(f, ", named_params: {{")?;
176			for (name, range) in &self.named_params {
177				write!(f, "{}: {}, ", name, self.get_param_range(range))?;
178			}
179			write!(f, "}}")?;
180		}
181
182		write!(f, " }}")
183	}
184}
185
186impl fmt::Display for TopicMatch {
187	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188		write!(f, "Match({})", self.topic.path)?;
189
190		if !self.params.is_empty() {
191			write!(f, " with {} params", self.params.len())?;
192		}
193
194		Ok(())
195	}
196}