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