mqtt-topic-engine 0.2.0

High-performance MQTT topic pattern matching and routing engine
Documentation
//! Matched-topic types.
//!
//! [`TopicPath`] is a concrete topic split into segments; [`TopicMatch`] is the
//! result of matching such a topic against a pattern, exposing the captured
//! positional and named parameters.

#![allow(clippy::missing_docs_in_private_items)]

use std::fmt;
use std::ops::Range;
use std::sync::Arc;

use arcstr::{ArcStr, Substr};
use smallvec::SmallVec;
use thiserror::Error;

/// A concrete MQTT topic, split into its `/`-delimited segments.
///
/// The original topic string and the segment slices share the same backing
/// [`ArcStr`] allocation, so cloning and slicing are cheap.
#[derive(Debug, Clone)]
pub struct TopicPath {
	/// The full topic string.
	pub path: ArcStr,
	/// The topic split on `/`; each segment is a slice into [`path`](Self::path).
	pub segments: Vec<Substr>,
}

impl TopicPath {
	/// Builds a [`TopicPath`] by splitting `path` on `/` into segments.
	///
	/// Rejects a topic that is not well-formed per MQTT §4.7.3: an empty string,
	/// or one containing the null character (U+0000). Every other UTF-8 topic —
	/// including spaces and the discouraged-but-legal U+0001..U+001F control
	/// range — is accepted. The 65535-byte length ceiling is left to the wire
	/// codec, not enforced here.
	pub fn new(path: impl Into<ArcStr>) -> Result<Self, TopicPathError> {
		let path = path.into();
		check_wellformed(&path)?;
		let segments: Vec<Substr> =
			path.split('/').map(|s| path.substr_from(s)).collect();
		Ok(Self { path, segments })
	}

	/// Returns a cheap (refcounted) clone of the full topic string.
	pub fn path(&self) -> ArcStr {
		self.path.clone()
	}
}

impl fmt::Display for TopicPath {
	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
		write!(f, "{}", self.path)
	}
}

/// MQTT §4.7.2: a topic filter whose first level is a wildcard (`+`/`#`) must
/// not match a topic name whose first level begins with `$` (the reserved
/// `$SYS`/`$share` space). The exclusion is first-level-only — `$` anywhere but
/// the leading character is an ordinary literal — so this tests just the first
/// segment. The one predicate is shared by both the trie and pattern matchers.
pub(crate) fn is_dollar_topic(first_segment: &str) -> bool {
	first_segment.starts_with('$')
}

/// The MQTT §4.7.3 well-formedness kernel shared by topic-name ([`TopicPath`])
/// and topic-filter ([`TopicPatternPath`](crate::topic_pattern_path::TopicPatternPath))
/// construction: a topic MUST be non-empty and MUST NOT contain the null
/// character (U+0000). Every other UTF-8 topic is accepted — spaces and the
/// discouraged-but-legal U+0001..U+001F control range included. Length is
/// deliberately not bounded here: the 65535-byte ceiling is a wire-codec
/// concern, not a routing one.
pub(crate) fn check_wellformed(topic: &str) -> Result<(), Malformed> {
	if topic.is_empty() {
		Err(Malformed::Empty)
	} else if topic.contains('\0') {
		Err(Malformed::NullChar)
	} else {
		Ok(())
	}
}

/// A topic-string well-formedness violation, mapped by each constructor to its
/// own public error type ([`TopicPathError`] / [`TopicPatternError`]).
///
/// [`TopicPatternError`]: crate::topic_pattern_item::TopicPatternError
pub(crate) enum Malformed {
	/// The topic string was empty.
	Empty,
	/// The topic string contained the null character (U+0000).
	NullChar,
}

/// Errors returned when constructing a [`TopicPath`] from a topic string
/// (MQTT §4.7.3 well-formedness).
#[derive(Error, Debug, Clone, PartialEq, Eq)]
pub enum TopicPathError {
	/// The topic was empty; a topic name must have at least one character.
	#[error("Topic cannot be empty")]
	Empty,

	/// The topic contained the null character (U+0000), which is forbidden.
	#[error("Topic must not contain the null character (U+0000)")]
	NullChar,
}

impl From<Malformed> for TopicPathError {
	fn from(malformed: Malformed) -> Self {
		match malformed {
			| Malformed::Empty => TopicPathError::Empty,
			| Malformed::NullChar => TopicPathError::NullChar,
		}
	}
}

/// Errors returned when matching a topic against a pattern.
#[derive(Error, Debug, Clone, PartialEq, Eq)]
pub enum TopicMatchError {
	/// Pattern ended unexpectedly while matching topic
	#[error("Pattern ended unexpectedly while matching topic")]
	UnexpectedEndOfPattern,

	/// Topic ended unexpectedly while matching pattern
	#[error("Topic ended unexpectedly while matching pattern")]
	UnexpectedEndOfTopic,

	/// Hash wildcard (#) found in unexpected position
	#[error("Hash wildcard (#) found in unexpected position")]
	UnexpectedHashSegment,

	/// Segment mismatch during topic matching
	#[error(
		"Segment mismatch at position {position}: expected '{expected}', \
		 found '{found}'"
	)]
	SegmentMismatch {
		/// Expected segment value
		expected: String,
		/// Actually found segment value
		found: String,
		/// Position where mismatch occurred
		position: usize,
	},

	/// Duplicate parameter name found in pattern
	#[error("Duplicate parameter name found in pattern")]
	DuplicateParameterName,

	/// The topic string being matched was not well-formed (MQTT §4.7.3).
	#[error(transparent)]
	InvalidTopic(#[from] TopicPathError),

	/// A topic filter with a leading `+`/`#` wildcard was matched against a
	/// `$`-prefixed topic, which MQTT §4.7.2 forbids at the first level.
	#[error(
		"Topic filter with a leading wildcard cannot match reserved $-topic \
		 '{topic}' (MQTT §4.7.2)"
	)]
	DollarTopicExclusion {
		/// The `$`-prefixed topic that the leading wildcard was excluded from.
		topic: String,
	},
}

/// The result of matching a [`TopicPath`] against a pattern.
///
/// Holds the matched topic plus the ranges of segments captured by the
/// pattern's wildcards, accessible by position ([`get_param`](Self::get_param))
/// or by name ([`get_named_param`](Self::get_named_param)).
///
/// `Clone` is cheap: an `Arc` bump for the shared path plus two small inline
/// vectors of segment ranges (no re-parsing, no string copies).
#[derive(Clone)]
pub struct TopicMatch {
	topic: Arc<TopicPath>,
	params: SmallVec<[Range<usize>; 3]>,
	named_params: SmallVec<[(Substr, Range<usize>); 3]>,
}

impl TopicMatch {
	pub(crate) fn from_match_result(
		topic: Arc<TopicPath>,
		params: SmallVec<[Range<usize>; 3]>,
		named_params: SmallVec<[(Substr, Range<usize>); 3]>,
	) -> Self {
		Self {
			topic,
			params,
			named_params,
		}
	}

	/// Returns the matched topic's segments.
	pub fn path_segments(&self) -> &Vec<Substr> {
		&self.topic.segments
	}

	fn get_param_range(&self, range: &Range<usize>) -> Substr {
		if range.is_empty() {
			self.topic.path.substr(0 .. 0)
		} else if range.len() == 1 {
			self.topic.segments[range.start].clone()
		} else {
			let start_segment = &self.topic.segments[range.start];
			let end_segment = &self.topic.segments[range.end - 1];

			let start_pos = start_segment.as_ptr() as usize
				- self.topic.path.as_ptr() as usize;
			let end_pos = end_segment.as_ptr() as usize
				- self.topic.path.as_ptr() as usize
				+ end_segment.len();

			self.topic.path.substr(start_pos .. end_pos)
		}
	}

	/// Returns the positional parameter captured at `index`, if any.
	///
	/// Parameters are numbered in pattern order; a `#` wildcard yields the
	/// joined remainder of the topic.
	pub fn get_param(&self, index: usize) -> Option<Substr> {
		self.params
			.get(index)
			.map(|range| self.get_param_range(range))
	}

	/// Returns the value of the named parameter `name`, if the pattern bound one.
	pub fn get_named_param(&self, name: &str) -> Option<Substr> {
		self.named_params
			.iter()
			.find(|(n, _)| n.as_str() == name)
			.map(|(_, range)| self.get_param_range(range))

		//self.named_params.get(name).map(|range| self.get_param_range(range))
	}

	/// Returns a cheap (refcounted) clone of the matched topic string.
	pub fn topic_path(&self) -> ArcStr {
		self.topic.path.clone()
	}
}

//Implement Debug for TopicMatch, using get_param and get_named_param
impl fmt::Debug for TopicMatch {
	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
		write!(f, "TopicMatch {{ topic: {}, params: [", self.topic.path)?;
		for (i, param) in self.params.iter().enumerate() {
			if i > 0 {
				write!(f, ", ")?;
			}
			write!(f, "{}", self.get_param_range(param))?;
		}
		write!(f, "]")?;

		if !self.named_params.is_empty() {
			write!(f, ", named_params: {{")?;
			for (name, range) in &self.named_params {
				write!(f, "{}: {}, ", name, self.get_param_range(range))?;
			}
			write!(f, "}}")?;
		}

		write!(f, " }}")
	}
}

impl fmt::Display for TopicMatch {
	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
		write!(f, "Match({})", self.topic.path)?;

		if !self.params.is_empty() {
			write!(f, " with {} params", self.params.len())?;
		}

		Ok(())
	}
}

#[cfg(test)]
mod tests {
	use super::*;

	#[test]
	fn rejects_empty_topic() {
		assert_eq!(TopicPath::new("").unwrap_err(), TopicPathError::Empty);
	}

	#[test]
	fn rejects_null_char() {
		assert_eq!(
			TopicPath::new("a/\0/b").unwrap_err(),
			TopicPathError::NullChar
		);
	}

	#[test]
	fn accepts_utf8_spaces_and_control_chars() {
		// Must not regress to the old ASCII-only gate.
		assert!(TopicPath::new("наприклад/日本語/😀").is_ok());
		// U+0001 is discouraged but spec-legal (§4.7.3).
		assert!(TopicPath::new("a/\u{1}/b").is_ok());
		// A space is a valid topic.
		assert!(TopicPath::new("   ").is_ok());
	}

	#[test]
	fn splits_into_segments() {
		let topic = TopicPath::new("a/b/c").unwrap();
		assert_eq!(topic.segments.len(), 3);
	}
}