Skip to main content

mqtt_topic_engine/
topic_matcher.rs

1//! Prefix-tree topic matcher.
2//!
3//! [`TopicMatcherNode`] is a trie keyed by topic segments that stores a payload
4//! `T` per subscription pattern (literals, `+` and `#` wildcards) and resolves
5//! all payloads matching a concrete topic. The [`Len`] trait lets a node prune
6//! empty payload containers during removal.
7
8#![allow(clippy::missing_docs_in_private_items)]
9use std::collections::{HashMap, HashSet};
10
11use arcstr::Substr;
12use thiserror::Error;
13
14use crate::topic_match::TopicPath;
15use crate::topic_pattern_item::TopicPatternItem;
16use crate::topic_pattern_path::TopicPatternPath;
17
18/// Errors that can occur during topic matching operations
19#[derive(Error, Debug, Clone, PartialEq, Eq)]
20pub enum TopicMatcherError {
21	/// Topic path provided for matching is empty
22	#[error("Topic path cannot be empty for matching")]
23	EmptyTopicPath,
24
25	/// Invalid topic segment encountered during matching
26	#[error("Invalid topic segment '{segment}' at position {position}")]
27	InvalidSegment {
28		/// The offending segment value.
29		segment: String,
30		/// Zero-based position of the segment within the path.
31		position: usize,
32	},
33
34	/// Topic path contains invalid UTF-8 characters
35	#[error("Topic path contains invalid UTF-8: {details}")]
36	InvalidUtf8 {
37		/// Details about the decoding failure.
38		details: String,
39	},
40}
41
42impl TopicMatcherError {
43	/// Creates a new InvalidSegment error
44	pub fn invalid_segment(
45		segment: impl Into<String>,
46		position: usize,
47	) -> Self {
48		Self::InvalidSegment {
49			segment: segment.into(),
50			position,
51		}
52	}
53
54	/// Creates a new InvalidUtf8 error
55	pub fn invalid_utf8(details: impl Into<String>) -> Self {
56		Self::InvalidUtf8 {
57			details: details.into(),
58		}
59	}
60}
61
62/// Node in the topic matching tree that represents a part of the topic path.
63/// Used internally by the `TopicMatcher`.
64#[derive(Debug)]
65pub struct TopicMatcherNode<T> {
66	/// Data for exact topic segment match
67	exact_match_data: Option<T>,
68
69	/// Children nodes for exact matches of next segment
70	exact_children: HashMap<Substr, TopicMatcherNode<T>>,
71
72	/// Node for '+' pattern wildcard match (single segment)
73	single_level_wildcard_node: Option<Box<TopicMatcherNode<T>>>,
74
75	/// Data for '#' pattern wildcard match (multiple segments)
76	multi_level_wildcard_data: Option<T>,
77}
78
79/// Abstraction over payload containers stored in a [`TopicMatcherNode`],
80/// used to detect when a node's payload has become empty and can be pruned.
81pub trait Len {
82	/// Number of elements currently held.
83	fn len(&self) -> usize;
84	/// Returns `true` when the container holds no elements.
85	fn is_empty(&self) -> bool {
86		self.len() == 0
87	}
88}
89
90impl<T> Len for HashSet<T> {
91	fn len(&self) -> usize {
92		self.len()
93	}
94	fn is_empty(&self) -> bool {
95		self.is_empty()
96	}
97}
98
99impl<K, V> Len for HashMap<K, V> {
100	fn len(&self) -> usize {
101		self.len()
102	}
103	fn is_empty(&self) -> bool {
104		self.is_empty()
105	}
106}
107
108impl<T: Default + Len> Default for TopicMatcherNode<T> {
109	fn default() -> Self {
110		Self::new()
111	}
112}
113
114impl<T: Default + Len> TopicMatcherNode<T> {
115	/// Creates a new empty topic matcher node
116	pub fn new() -> Self {
117		Self {
118			exact_match_data: None,
119			exact_children: HashMap::new(),
120			single_level_wildcard_node: None,
121			multi_level_wildcard_data: None,
122		}
123	}
124
125	/// Returns `true` when this node holds no payload and has no children,
126	/// i.e. it carries no subscriptions and can be removed by its parent.
127	pub fn is_empty(&self) -> bool {
128		self.exact_match_data.as_ref().is_none_or(T::is_empty)
129			&& self.exact_children.is_empty()
130			&& self.single_level_wildcard_node.is_none()
131			&& self
132				.multi_level_wildcard_data
133				.as_ref()
134				.is_none_or(T::is_empty)
135	}
136	/// Finds or creates a subscription data entry matching the given topic pattern
137	pub fn get_or_create_subscription_table(
138		&mut self,
139		topic_path: &TopicPatternPath,
140	) -> &mut T {
141		let mut current_node = self;
142
143		let resolved_segments = topic_path.resolve_bound_segments();
144		for segment in resolved_segments {
145			match segment {
146				| TopicPatternItem::Str(s) => {
147					current_node = current_node
148						.exact_children
149						.entry(s.clone())
150						.or_default()
151				}
152				| TopicPatternItem::Plus(_) => {
153					current_node = current_node
154						.single_level_wildcard_node
155						.get_or_insert_with(
156							|| Box::new(TopicMatcherNode::new()),
157						)
158				}
159				| TopicPatternItem::Hash(_) => {
160					// Hash wildcard must be the last segment, so we can return immediately
161					return current_node
162						.multi_level_wildcard_data
163						.get_or_insert_with(T::default);
164				}
165			}
166		}
167		current_node.exact_match_data.get_or_insert_with(T::default)
168	}
169
170	/// Finds or creates a subscription data entry matching the given topic pattern
171	pub fn update_node<F>(
172		&mut self,
173		topic_path: &[TopicPatternItem],
174		mut f: F,
175	) -> Result<bool, TopicMatcherError>
176	where
177		F: FnMut(&mut T),
178	{
179		if topic_path.is_empty() {
180			let data = self.exact_match_data.as_mut().ok_or_else(|| {
181				TopicMatcherError::invalid_segment(
182					"no_data_for_empty_path".to_string(),
183					0,
184				)
185			})?;
186			f(data);
187			if data.is_empty() {
188				self.exact_match_data = None
189			}
190			return Ok(self.is_empty());
191		}
192		let current_segment = &topic_path[0];
193		let rest_segments = &topic_path[1 ..];
194
195		match current_segment {
196			| TopicPatternItem::Str(s) => {
197				let child_node =
198					self.exact_children.get_mut(s).ok_or_else(|| {
199						TopicMatcherError::invalid_segment(s.as_str(), 0)
200					})?;
201				if child_node.update_node(rest_segments, f)? {
202					self.exact_children.remove(s);
203					return Ok(self.is_empty());
204				}
205			}
206			| TopicPatternItem::Plus(_) => {
207				let child_node = self
208					.single_level_wildcard_node
209					.as_mut()
210					.ok_or_else(|| {
211						TopicMatcherError::invalid_segment("+".to_string(), 0)
212					})?;
213				if child_node.update_node(rest_segments, f)? {
214					self.single_level_wildcard_node = None;
215					return Ok(self.is_empty());
216				}
217			}
218			| TopicPatternItem::Hash(_) => {
219				let hash_wildcard_data = self
220					.multi_level_wildcard_data
221					.as_mut()
222					.ok_or_else(|| {
223						TopicMatcherError::invalid_segment("#".to_string(), 0)
224					})?;
225				f(hash_wildcard_data);
226				if hash_wildcard_data.is_empty() {
227					self.multi_level_wildcard_data = None;
228					return Ok(self.is_empty());
229				}
230			}
231		}
232		Ok(false)
233	}
234
235	/// Recursively collects all subscription data that matches the given topic path segments
236	fn collect_matching_subscriptions<'a>(
237		&'a self,
238		topic: &[Substr],
239		matching_data: &mut Vec<&'a T>,
240	) {
241		match topic {
242			| [] => {
243				// At end of path, collect data from this node if present
244				self.exact_match_data
245					.iter()
246					.for_each(|data| matching_data.push(data));
247				self.multi_level_wildcard_data
248					.iter()
249					.for_each(|data| matching_data.push(data))
250			}
251			| [segment, remaining_segments @ ..] => {
252				// Check for exact segment match
253				if let Some(child) = self.exact_children.get(segment) {
254					child.collect_matching_subscriptions(
255						remaining_segments,
256						matching_data,
257					);
258				}
259				// Check for + wildcard match (matches any single segment)
260				self.single_level_wildcard_node
261					.iter()
262					.for_each(|plus_node| {
263						plus_node.collect_matching_subscriptions(
264							remaining_segments,
265							matching_data,
266						)
267					});
268				// # wildcard matches remainder of path
269				self.multi_level_wildcard_data
270					.iter()
271					.for_each(|hash_data| matching_data.push(hash_data));
272			}
273		}
274	}
275
276	/// Finds all subscription data entries matching the given topic path
277	pub fn find_by_path<'a>(&'a self, topic: &TopicPath) -> Vec<&'a T> {
278		//let path_segments: Vec<&str> = path.split('/').collect();
279		let mut matching_subscribers = Vec::new();
280		self.collect_matching_subscriptions(
281			&topic.segments,
282			&mut matching_subscribers,
283		);
284		matching_subscribers
285	}
286
287	#[cfg(test)]
288	// NOTE: These methods are only available in test builds and are used for
289	// testing the tree traversal logic. In production, use TopicRouter::get_active_subscriptions()
290	// which is more efficient.
291	fn collect_active_subscriptions_internal<'a>(
292		&'a self,
293		current_path: &mut Vec<TopicPatternItem>,
294		result: &mut Vec<(TopicPatternPath, &'a T)>,
295	) {
296		// Collect exact match data if present
297		if let Some(data) = &self.exact_match_data {
298			let path =
299				TopicPatternPath::new_from_segments(current_path.as_slice())
300					.expect("Internal path should always be valid");
301			result.push((path, data))
302		};
303		// Collect hash wildcard data if present
304		if let Some(data) = &self.multi_level_wildcard_data {
305			current_path.push(TopicPatternItem::Hash(None));
306			let topic_path =
307				TopicPatternPath::new_from_segments(current_path.as_slice())
308					.expect("Internal path should always be valid");
309			result.push((topic_path, data));
310			current_path.pop();
311		};
312		if let Some(plus_node) = &self.single_level_wildcard_node {
313			current_path.push(TopicPatternItem::Plus(None));
314			plus_node
315				.collect_active_subscriptions_internal(current_path, result);
316			current_path.pop();
317		};
318		for (exact_segment, child) in &self.exact_children {
319			current_path.push(TopicPatternItem::Str(exact_segment.clone()));
320			child.collect_active_subscriptions_internal(current_path, result);
321			current_path.pop();
322		}
323	}
324
325	/// Collects every stored `(pattern, payload)` by walking the trie.
326	///
327	/// Test-only helper used to assert routing-tree contents; production code
328	/// should use `TopicRouter::get_active_subscriptions`, which is cheaper.
329	#[cfg(test)]
330	pub fn collect_active_subscriptions(&self) -> Vec<(TopicPatternPath, &T)> {
331		let mut result = Vec::new();
332		self.collect_active_subscriptions_internal(
333			&mut Vec::new(),
334			&mut result,
335		);
336		result
337	}
338}