Skip to main content

mqtt_topic_engine/
topic_pattern_item.rs

1//! MQTT topic pattern item types and functionality
2
3use std::borrow::Cow;
4use std::convert::TryFrom;
5
6use arcstr::Substr;
7use thiserror::Error;
8
9/// Error types for topic pattern parsing
10#[derive(Error, Debug, Clone, PartialEq, Eq)]
11pub enum TopicPatternError {
12	/// Hash wildcard (#) used not at the end of the pattern
13	#[error(
14		"Invalid topic pattern '{pattern}': # wildcard can only be the last \
15		 segment"
16	)]
17	HashPosition {
18		/// The invalid pattern
19		pattern: String,
20	},
21
22	/// Wildcard characters (+ or #) used incorrectly
23	#[error("Invalid wildcard usage: {usage}")]
24	WildcardUsage {
25		/// Description of invalid usage
26		usage: String,
27	},
28
29	/// Empty topic is not valid
30	#[error("Topic pattern cannot be empty")]
31	EmptyTopic,
32
33	/// Topic pattern structure mismatch when trying to use compatible pattern
34	#[error(
35		"Topic pattern structure mismatch.\nOriginal: '{original}'\nCustom:   \
36		 '{custom}'\nHint: Both patterns must have the same parameter \
37		 structure (same wildcards in same positions)"
38	)]
39	PatternStructureMismatch {
40		/// Original pattern from the struct
41		original: String,
42		/// Custom pattern that doesn't match
43		custom: String,
44	},
45}
46
47impl TopicPatternError {
48	/// Creates a new HashPosition error
49	pub fn hash_position(pattern: impl Into<String>) -> Self {
50		Self::HashPosition {
51			pattern: pattern.into(),
52		}
53	}
54
55	/// Creates a new WildcardUsage error
56	pub fn wildcard_usage(usage: impl Into<String>) -> Self {
57		Self::WildcardUsage {
58			usage: usage.into(),
59		}
60	}
61
62	/// Creates a new PatternStructureMismatch error
63	pub fn pattern_mismatch(
64		original: impl Into<String>,
65		custom: impl Into<String>,
66	) -> Self {
67		Self::PatternStructureMismatch {
68			original: original.into(),
69			custom: custom.into(),
70		}
71	}
72}
73
74impl From<std::convert::Infallible> for TopicPatternError {
75	fn from(_: std::convert::Infallible) -> Self {
76		unreachable!("Infallible can never be constructed")
77	}
78}
79
80/// MQTT topic pattern segment: literal string or wildcard
81#[derive(Debug, Clone, PartialEq, Eq, Hash)]
82pub enum TopicPatternItem {
83	/// Literal string segment
84	Str(Substr),
85	/// Single-level wildcard `+` or named `{param}`
86	Plus(Option<Substr>),
87	/// Multi-level wildcard `#` or named `{param:#}`
88	Hash(Option<Substr>),
89}
90
91impl TopicPatternItem {
92	/// Returns string representation of the pattern item.
93	pub fn as_str(&self) -> &str {
94		match self {
95			| TopicPatternItem::Str(s) => s,
96			| TopicPatternItem::Plus(_) => "+",
97			| TopicPatternItem::Hash(_) => "#",
98		}
99	}
100
101	/// Returns pattern representation with named parameters in braces.
102	pub fn as_wildcard(&self) -> Cow<'_, str> {
103		match self {
104			| TopicPatternItem::Plus(None) => Cow::Borrowed("+"),
105			| TopicPatternItem::Hash(None) => Cow::Borrowed("#"),
106			| TopicPatternItem::Plus(Some(name)) => {
107				Cow::Owned(format!("{{{name}}}"))
108			}
109			| TopicPatternItem::Hash(Some(name)) => {
110				Cow::Owned(format!("{{{name}:#}}"))
111			}
112			| TopicPatternItem::Str(s) => Cow::Borrowed(s),
113		}
114	}
115
116	/// Returns parameter name for named wildcards.
117	pub fn param_name(&self) -> Option<Substr> {
118		match self {
119			| TopicPatternItem::Plus(Some(name))
120			| TopicPatternItem::Hash(Some(name)) => Some(name.clone()),
121			| _ => None,
122		}
123	}
124
125	/// Returns true if this item is a wildcard (+ or #).
126	pub fn is_wildcard(&self) -> bool {
127		matches!(self, TopicPatternItem::Plus(_) | TopicPatternItem::Hash(_))
128	}
129}
130
131impl From<&TopicPatternItem> for String {
132	fn from(item: &TopicPatternItem) -> Self {
133		item.as_str().to_string()
134	}
135}
136
137impl std::fmt::Display for TopicPatternItem {
138	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139		write!(f, "{}", self.as_str())
140	}
141}
142
143impl TryFrom<Substr> for TopicPatternItem {
144	type Error = TopicPatternError;
145	fn try_from(item: Substr) -> Result<Self, Self::Error> {
146		let res = match item.as_str() {
147			| "+" => TopicPatternItem::Plus(None),
148			| "#" => TopicPatternItem::Hash(None),
149			| _ if item.starts_with("{") && item.ends_with(":#}") => {
150				let inner =
151					item.trim_start_matches('{').trim_end_matches(":#}");
152				if inner.is_empty() {
153					return Err(TopicPatternError::wildcard_usage(
154						item.as_str(),
155					));
156				}
157				TopicPatternItem::Hash(Some(item.substr_from(inner)))
158			}
159			| _ if item.starts_with("{") && item.ends_with("}") => {
160				let inner = item.trim_start_matches('{').trim_end_matches("}");
161				if inner.is_empty() {
162					return Err(TopicPatternError::wildcard_usage(
163						item.as_str(),
164					));
165				}
166				TopicPatternItem::Plus(Some(item.substr_from(inner)))
167			}
168			| _ if item.contains(['+', '#']) => {
169				return Err(TopicPatternError::wildcard_usage(item.as_str()));
170			}
171			| _ => TopicPatternItem::Str(item),
172		};
173		Ok(res)
174	}
175}