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