Skip to main content

mqtt_topic_engine/
topic_pattern_path.rs

1//! Parsed MQTT topic patterns.
2//!
3//! [`TopicPatternPath`] holds a validated sequence of [`TopicPatternItem`]
4//! segments (literals, `+` single-level and `#` multi-level wildcards) together
5//! with bound parameter values, and renders back to the wire MQTT pattern.
6
7use std::collections::HashSet;
8use std::convert::TryFrom;
9use std::fmt::{self, Display, Write};
10use std::slice::Iter;
11use std::sync::Arc;
12#[cfg(feature = "lru-cache")]
13use std::sync::Mutex;
14
15use arcstr::ArcStr;
16#[cfg(feature = "lru-cache")]
17use lru::LruCache;
18use smallvec::SmallVec;
19use thiserror::Error;
20
21use crate::cache_strategy::CacheStrategy;
22use crate::topic_match::{
23	TopicMatch, TopicMatchError, TopicPath, check_wellformed, is_dollar_topic,
24};
25use crate::topic_pattern_item::{TopicPatternError, TopicPatternItem};
26
27/// Error types for formatting topics with parameters
28#[derive(Error, Debug, Clone, PartialEq, Eq)]
29pub enum TopicFormatError {
30	/// Attempted to format a topic with a hash wildcard (#) which is not allowed
31	#[error("Cannot format topic with # wildcard for publishing")]
32	HashWildcardNotSupported,
33
34	/// Parameter count mismatch when formatting a topic
35	#[error(
36		"Parameter count mismatch: expected {expected}, provided {provided}"
37	)]
38	ParameterCountMismatch {
39		/// Expected number of parameters
40		expected: usize,
41		/// Number of parameters actually provided
42		provided: usize,
43	},
44	/// Error during formatting, e.g. invalid parameter type
45	#[error("Error formatting topic")]
46	FormatError {
47		#[source]
48		/// The underlying formatting error
49		source: fmt::Error,
50	},
51}
52
53impl From<fmt::Error> for TopicFormatError {
54	fn from(source: fmt::Error) -> Self {
55		TopicFormatError::FormatError { source }
56	}
57}
58
59/// Parsed MQTT topic pattern with wildcard support
60#[derive(Debug)]
61pub struct TopicPatternPath {
62	template_pattern: ArcStr, // original topic pattern as a string
63	mqtt_topic_subscription: ArcStr, // mqtt topic pattern with wildcards "sensors/+/data"
64	segments: Vec<TopicPatternItem>,
65	/// Optional LRU cache for topic match results.
66	///
67	/// Uses `Mutex` instead of `RefCell` for interior mutability because:
68	/// 1. This struct needs to be `Send + Sync` to work in the actor-based subscription manager
69	/// 2. Although used in single-threaded actor context, `RefCell` is not `Send`
70	/// 3. No contention occurs since access is serialized within the actor's event loop
71	/// 4. `Mutex` provides the same interior mutability as `RefCell` but with `Send + Sync`
72	///
73	/// **Note:** This field is only available with the `lru-cache` feature enabled.
74	#[cfg(feature = "lru-cache")]
75	match_cache: Option<Mutex<LruCache<ArcStr, Arc<TopicMatch>>>>,
76
77	parameter_bindings: Option<SmallVec<[(ArcStr, ArcStr); 4]>>,
78}
79
80impl Clone for TopicPatternPath {
81	fn clone(&self) -> Self {
82		Self {
83			template_pattern: self.template_pattern.clone(),
84			mqtt_topic_subscription: self.mqtt_topic_subscription.clone(),
85			segments: self.segments.clone(),
86			#[cfg(feature = "lru-cache")]
87			match_cache: self.match_cache.as_ref().map(|cache| {
88				let cache_guard = cache.lock().unwrap();
89				let capacity = cache_guard.cap();
90				drop(cache_guard);
91				Mutex::new(LruCache::new(capacity))
92			}),
93			parameter_bindings: self.parameter_bindings.clone(),
94		}
95	}
96}
97
98impl TopicPatternPath {
99	/// Creates a topic pattern from string with optional caching.
100	pub fn new_from_string(
101		topic_pattern: impl Into<ArcStr>,
102		cache_strategy: CacheStrategy,
103	) -> Result<Self, TopicPatternError> {
104		let topic_pattern = topic_pattern.into();
105		// MQTT §4.7.3 well-formedness kernel (non-empty, no U+0000), shared with
106		// `TopicPath::new`. A whitespace-only pattern is a valid literal topic and
107		// is intentionally NOT rejected here.
108		check_wellformed(&topic_pattern)?;
109
110		let segments: Result<Vec<_>, _> = topic_pattern
111			.split('/')
112			.map(|s| topic_pattern.substr_from(s))
113			.map(TopicPatternItem::try_from)
114			.collect();
115
116		let segments = segments?;
117
118		//Error on duplicate named parameters
119		let mut seen_names = HashSet::new();
120		for segment in &segments {
121			if let Some(name) = segment.param_name() {
122				if !seen_names.insert(name.to_string()) {
123					return Err(TopicPatternError::wildcard_usage(
124						segment.as_str(),
125					));
126				}
127			}
128		}
129
130		if let Some(hash_pos) = segments
131			.iter()
132			.position(|s| matches!(*s, TopicPatternItem::Hash(_)))
133		{
134			if hash_pos != segments.len() - 1 {
135				return Err(TopicPatternError::hash_position(
136					topic_pattern.as_str(),
137				));
138			}
139		}
140
141		#[cfg(feature = "lru-cache")]
142		let match_cache = match cache_strategy {
143			| CacheStrategy::Lru(cache_size) => {
144				Some(Mutex::new(LruCache::new(cache_size)))
145			}
146			| CacheStrategy::NoCache => None,
147		};
148
149		#[cfg(not(feature = "lru-cache"))]
150		{
151			if let Some(capacity) = cache_strategy.capacity() {
152				tracing::warn!(
153					capacity = capacity.get(),
154					pattern = %topic_pattern,
155					"LRU cache strategy provided for topic pattern '{}' with capacity {}, \
156					but 'lru-cache' feature is disabled. Caching will not be used. \
157					Enable 'lru-cache' feature in Cargo.toml to use caching.",
158					topic_pattern,
159					capacity.get()
160				);
161			}
162		}
163
164		Ok(Self {
165			template_pattern: topic_pattern,
166			mqtt_topic_subscription: ArcStr::from(
167				Self::to_mqtt_subscription_pattern(&segments),
168			),
169			segments,
170			#[cfg(feature = "lru-cache")]
171			match_cache,
172			parameter_bindings: None,
173		})
174	}
175
176	/// Get the cache strategy of this topic pattern.
177	pub fn cache_strategy(&self) -> CacheStrategy {
178		#[cfg(feature = "lru-cache")]
179		{
180			match &self.match_cache {
181				| Some(cache_mutex) => {
182					let cache_guard = cache_mutex.lock().unwrap();
183					CacheStrategy::Lru(cache_guard.cap())
184				}
185				| None => CacheStrategy::NoCache,
186			}
187		}
188		#[cfg(not(feature = "lru-cache"))]
189		{
190			CacheStrategy::NoCache
191		}
192	}
193
194	/// Returns the current parameter bindings, if any.
195	pub fn parameter_bindings(
196		&self,
197	) -> Option<&SmallVec<[(ArcStr, ArcStr); 4]>> {
198		self.parameter_bindings.as_ref()
199	}
200
201	/// Returns the bound value for a named parameter, if it exists.
202	pub fn get_bound_value(&self, param_name: Option<&str>) -> Option<&ArcStr> {
203		let name = param_name?; // Якщо None - одразу повертаємо None
204		self.parameter_bindings
205			.as_ref()?
206			.iter()
207			.find(|(binding_name, _)| binding_name == name)
208			.map(|(_, value)| value)
209	}
210
211	#[cfg(all(test, feature = "router"))]
212	/// Creates a topic pattern from segments directly, useful for testing.
213	pub(crate) fn new_from_segments(
214		segments: &[TopicPatternItem],
215	) -> Result<Self, TopicPatternError> {
216		let topic_pattern = ArcStr::from(Self::to_template_pattern(segments));
217		let pattern = Self {
218			mqtt_topic_subscription: ArcStr::from(
219				Self::to_mqtt_subscription_pattern(segments),
220			),
221			template_pattern: topic_pattern.clone(),
222			segments: segments.to_vec(),
223			#[cfg(feature = "lru-cache")]
224			match_cache: None,
225			parameter_bindings: None,
226		};
227		if let Some(hash_pos) = segments
228			.iter()
229			.position(|s| matches!(*s, TopicPatternItem::Hash(_)))
230		{
231			if hash_pos != segments.len() - 1 {
232				return Err(TopicPatternError::hash_position(
233					topic_pattern.as_str(),
234				));
235			}
236		}
237		Ok(pattern)
238	}
239
240	/// Returns MQTT pattern with wildcards for broker subscription with bound parameters applied.
241	pub fn mqtt_pattern(&self) -> ArcStr {
242		match &self.parameter_bindings {
243			| Some(bindings) => {
244				let new_segments = self.apply_bindings_to_segments(bindings);
245				ArcStr::from(Self::to_mqtt_subscription_pattern(&new_segments))
246			}
247			| None => self.mqtt_topic_subscription.clone(),
248		}
249	}
250
251	/// Resolves bound parameters into concrete segments
252	///
253	/// Returns segments with bound parameters replaced by their values.
254	/// Unbound wildcards remain as wildcards.
255	pub fn resolve_bound_segments(&self) -> Vec<TopicPatternItem> {
256		if let Some(ref bindings) = self.parameter_bindings {
257			self.apply_bindings_to_segments(bindings)
258		} else {
259			self.segments.clone()
260		}
261	}
262
263	// Internal helper that applies bindings to segments
264	fn apply_bindings_to_segments(
265		&self,
266		bindings: &[(ArcStr, ArcStr)],
267	) -> Vec<TopicPatternItem> {
268		let mut new_segments = self.segments.clone();
269
270		for (param_name, value) in bindings {
271			if let Some(segment_pos) = new_segments.iter().position(|segment| {
272                matches!(segment, TopicPatternItem::Plus(Some(name)) if name == param_name)
273            }) {
274                new_segments[segment_pos] = TopicPatternItem::Str(value.into());
275            } else {
276				tracing::debug!(
277					pattern = %self.topic_pattern(),
278					"Parameter '{param_name}' not found in pattern"
279				);
280            }
281		}
282
283		new_segments
284	}
285
286	/// Returns original pattern with named parameters.
287	pub fn topic_pattern(&self) -> ArcStr {
288		self.template_pattern.clone()
289	}
290
291	/// Returns true if pattern has no segments.
292	pub fn is_empty(&self) -> bool {
293		self.segments.is_empty()
294	}
295
296	/// Returns true if pattern contains multi-level wildcard (#).
297	pub fn contains_hash(&self) -> bool {
298		self.segments
299			.last()
300			.is_some_and(|s| matches!(s, TopicPatternItem::Hash(_)))
301	}
302
303	/// Returns iterator over pattern segments.
304	pub fn iter(&self) -> Iter<'_, TopicPatternItem> {
305		self.segments.iter()
306	}
307
308	/// Returns number of segments in pattern.
309	pub fn len(&self) -> usize {
310		self.segments.len()
311	}
312
313	fn str_len(segments: &[TopicPatternItem]) -> usize {
314		if segments.is_empty() {
315			return 0;
316		}
317		(segments.len() - 1) + // slashes count
318		segments.iter().map(|s| s.as_str().len()).sum::<usize>()
319	}
320
321	/// Returns pattern segments as slice.
322	pub fn slice(&self) -> &[TopicPatternItem] {
323		&self.segments
324	}
325
326	/// Returns pattern segments for testing.
327	#[cfg(test)]
328	pub fn segments(&self) -> &Vec<TopicPatternItem> {
329		&self.segments
330	}
331
332	/// Formats topic by substituting wildcards with provided parameters
333	pub fn format_topic(
334		&self,
335		params: &[&dyn Display],
336	) -> Result<String, TopicFormatError> {
337		let wildcard_count =
338			self.segments.iter().filter(|s| s.is_wildcard()).count();
339
340		if params.len() != wildcard_count {
341			return Err(TopicFormatError::ParameterCountMismatch {
342				expected: wildcard_count,
343				provided: params.len(),
344			});
345		}
346
347		let mut result = String::with_capacity(self.topic_pattern().len() + 10); //Est.
348		let mut param_index = 0;
349
350		for (i, segment) in self.segments.iter().enumerate() {
351			if i > 0 {
352				result.push('/');
353			}
354
355			match segment {
356				| TopicPatternItem::Str(s) => result.push_str(s),
357				| TopicPatternItem::Plus(_) => {
358					write!(result, "{}", params[param_index])?;
359					param_index += 1;
360				}
361				| TopicPatternItem::Hash(_) => {
362					return Err(TopicFormatError::HashWildcardNotSupported);
363				}
364			}
365		}
366
367		Ok(result)
368	}
369
370	fn to_mqtt_subscription_pattern(segments: &[TopicPatternItem]) -> String {
371		// Convert to MQTT wildcards: sensors/+/data
372		if segments.is_empty() {
373			return String::new();
374		}
375		let mut mqtt_topic = String::with_capacity(Self::str_len(segments));
376		segments.iter().enumerate().for_each(|(i, segment)| {
377			if i > 0 {
378				mqtt_topic.push('/');
379			}
380			mqtt_topic.push_str(segment.as_str());
381		});
382		mqtt_topic
383	}
384
385	#[cfg(all(test, feature = "router"))]
386	fn to_template_pattern(segments: &[TopicPatternItem]) -> String {
387		// Convert to named wildcards: sensors/{sensor_id}/data
388		if segments.is_empty() {
389			return String::new();
390		}
391		let mut mqtt_topic = String::new();
392		segments.iter().enumerate().for_each(|(i, segment)| {
393			if i > 0 {
394				mqtt_topic.push('/');
395			}
396			mqtt_topic.push_str(segment.as_wildcard().as_ref());
397		});
398		mqtt_topic
399	}
400
401	/// Checks if the provided topic pattern is compatible with this one.
402	///
403	/// Static segments can differ, but wildcards must be identical in type,
404	/// order, and names (if named).
405	pub fn check_pattern_compatibility(
406		&self,
407		custom_topic: impl TryInto<TopicPatternPath, Error: Into<TopicPatternError>>,
408	) -> Result<Self, TopicPatternError> {
409		let candidate = custom_topic.try_into().map_err(Into::into)?;
410		// Validate wildcard structure compatibility
411		let self_wildcards =
412			self.segments.iter().filter(|item| item.is_wildcard());
413		let candidate_wildcards =
414			candidate.segments.iter().filter(|item| item.is_wildcard());
415
416		if !self_wildcards.eq(candidate_wildcards) {
417			return Err(TopicPatternError::pattern_mismatch(
418				self.template_pattern.as_str(),
419				candidate.template_pattern.as_str(),
420			));
421		}
422
423		Ok(candidate)
424	}
425
426	/// Create new pattern with different cache strategy
427	pub fn with_cache_strategy(&self, new_cache: CacheStrategy) -> Self {
428		let mut new_pattern =
429			Self::new_from_string(self.template_pattern.clone(), new_cache)
430				.expect("Pattern already validated");
431		new_pattern.parameter_bindings = self.parameter_bindings.clone();
432		new_pattern
433	}
434
435	/// Add value for topic wildcard parameter
436	pub fn bind_parameter(
437		mut self,
438		param_name: impl Into<ArcStr>,
439		value: impl Into<ArcStr>,
440	) -> Result<Self, TopicPatternError> {
441		let param_name_arc = param_name.into();
442
443		let param_exists = self.segments.iter().any(|segment| {
444			matches!(segment, TopicPatternItem::Plus(Some(name)) if name.as_str() == param_name_arc.as_str())
445		});
446		if !param_exists {
447			return Err(TopicPatternError::wildcard_usage(format!(
448				"Parameter '{param_name_arc}' not found in pattern '{}'",
449				self.topic_pattern()
450			)));
451		}
452
453		let value_arc = value.into();
454
455		let bindings =
456			self.parameter_bindings.get_or_insert_with(SmallVec::new);
457
458		if let Some(pos) =
459			bindings.iter().position(|(k, _)| k == &param_name_arc)
460		{
461			bindings[pos].1 = value_arc;
462		} else {
463			bindings.push((param_name_arc, value_arc));
464		}
465
466		Ok(self)
467	}
468
469	/// Matches a topic against this pattern, extracting parameters.
470	///
471	/// Takes an `Arc<TopicPath>` so the topic can be shared cheaply: when
472	/// matching ONE topic against MANY patterns (the hot path), build the
473	/// `Arc<TopicPath>` once and pass `Arc::clone(&topic)` to each pattern to
474	/// avoid re-parsing and re-allocating per match. For a single one-off match
475	/// from a string, [`try_match_str`](Self::try_match_str) is more convenient.
476	pub fn try_match(
477		&self,
478		topic: Arc<TopicPath>,
479	) -> Result<Arc<TopicMatch>, TopicMatchError> {
480		#[cfg(feature = "lru-cache")]
481		{
482			match &self.match_cache {
483				| Some(cache_mutex) => {
484					{
485						let mut match_cache = cache_mutex.lock().unwrap();
486						if let Some(cached_match) = match_cache.get(&topic.path)
487						{
488							return Ok(cached_match.clone());
489						}
490					}
491
492					let topic_match = self.try_match_internal(topic.clone())?;
493					let topic_match_arc = Arc::new(topic_match);
494					{
495						let mut match_cache = cache_mutex.lock().unwrap();
496						match_cache.put(
497							topic.path.clone(),
498							Arc::clone(&topic_match_arc),
499						);
500					}
501					Ok(topic_match_arc)
502				}
503				| None => {
504					let topic_match = self.try_match_internal(topic)?;
505					Ok(Arc::new(topic_match))
506				}
507			}
508		}
509		#[cfg(not(feature = "lru-cache"))]
510		{
511			let topic_match = self.try_match_internal(topic)?;
512			Ok(Arc::new(topic_match))
513		}
514	}
515
516	/// Convenience wrapper around [`try_match`](Self::try_match) for one-off
517	/// matches: it builds the [`TopicPath`] and wraps it in an `Arc` for you.
518	///
519	/// Prefer [`try_match`](Self::try_match) on the hot path (one topic, many
520	/// patterns): calling this in a loop re-parses and re-allocates the topic
521	/// every time.
522	pub fn try_match_str(
523		&self,
524		topic: impl Into<ArcStr>,
525	) -> Result<Arc<TopicMatch>, TopicMatchError> {
526		self.try_match(Arc::new(TopicPath::new(topic)?))
527	}
528
529	/// Whether the pattern's first level is a wildcard that has NOT been bound
530	/// to a concrete value. A bound named wildcard resolves to a literal (and is
531	/// stored in the trie as one), so for the §4.7.2 `$`-exclusion it must not
532	/// count as a wildcard.
533	fn first_level_is_unbound_wildcard(&self) -> bool {
534		match self.segments.first() {
535			| Some(TopicPatternItem::Plus(Some(name))) => {
536				self.get_bound_value(Some(name.as_str())).is_none()
537			}
538			| Some(item) => item.is_wildcard(),
539			| None => false,
540		}
541	}
542
543	#[allow(clippy::missing_docs_in_private_items)]
544	fn try_match_internal(
545		&self,
546		topic: Arc<TopicPath>,
547	) -> Result<TopicMatch, TopicMatchError> {
548		// MQTT §4.7.2: a filter whose first level is an UNBOUND wildcard (`+`/`#`,
549		// incl. unbound named `{id}`/`{id:#}`) must not match a topic whose first
550		// level begins with `$`. A parameter bound to a `$`-value is a concrete
551		// literal — the trie stores it as an exact child — so judging on
552		// `self.segments` alone would make the two matchers disagree. Deeper
553		// wildcards after a literal first level (`$SYS/#`) are unaffected: the
554		// guard is strictly level-0.
555		if topic.segments.first().is_some_and(|s| is_dollar_topic(s))
556			&& self.first_level_is_unbound_wildcard()
557		{
558			return Err(TopicMatchError::DollarTopicExclusion {
559				topic: topic.path.to_string(),
560			});
561		}
562
563		let mut topic_index = 0;
564		let mut params = SmallVec::new();
565		let mut named_params = SmallVec::new();
566		for (i, pattern_segment) in self.iter().enumerate() {
567			match pattern_segment {
568				| TopicPatternItem::Str(expected) => {
569					if topic_index >= topic.segments.len() {
570						return Err(TopicMatchError::UnexpectedEndOfTopic);
571					}
572					if topic.segments[topic_index] != *expected {
573						return Err(TopicMatchError::SegmentMismatch {
574							expected: expected.to_string(),
575							found: topic.segments[topic_index].to_string(),
576							position: topic_index,
577						});
578					}
579					topic_index += 1;
580				}
581				| TopicPatternItem::Plus(opt_name) => {
582					if topic_index >= topic.segments.len() {
583						return Err(TopicMatchError::UnexpectedEndOfTopic);
584					}
585					let param_range = topic_index .. topic_index + 1;
586					params.push(param_range.clone());
587					topic_index += 1;
588					if let Some(name) = opt_name {
589						named_params.push((name.clone(), param_range));
590					}
591				}
592				| TopicPatternItem::Hash(opt_name) => {
593					let param_range = topic_index .. topic.segments.len();
594					params.push(param_range.clone());
595					if let Some(name) = opt_name {
596						named_params.push((name.clone(), param_range));
597					}
598					if i < self.len() - 1 {
599						return Err(TopicMatchError::UnexpectedHashSegment);
600					}
601					return Ok(TopicMatch::from_match_result(
602						topic,
603						params,
604						named_params,
605					));
606				}
607			}
608		}
609		if topic_index < topic.segments.len() {
610			return Err(TopicMatchError::UnexpectedEndOfPattern);
611		}
612		Ok(TopicMatch::from_match_result(topic, params, named_params))
613	}
614}
615
616impl std::fmt::Display for TopicPatternPath {
617	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
618		// Convert segments to strings and join them with "/"
619		let path = self.topic_pattern();
620		write!(f, "{path}")
621	}
622}
623
624impl TryFrom<String> for TopicPatternPath {
625	type Error = TopicPatternError;
626
627	fn try_from(value: String) -> Result<Self, Self::Error> {
628		Self::new_from_string(value, CacheStrategy::NoCache)
629	}
630}
631
632impl TryFrom<&str> for TopicPatternPath {
633	type Error = TopicPatternError;
634
635	fn try_from(value: &str) -> Result<Self, Self::Error> {
636		Self::new_from_string(value, CacheStrategy::NoCache)
637	}
638}
639
640impl TryFrom<ArcStr> for TopicPatternPath {
641	type Error = TopicPatternError;
642
643	fn try_from(value: ArcStr) -> Result<Self, Self::Error> {
644		Self::new_from_string(value, CacheStrategy::NoCache)
645	}
646}