Skip to main content

nautilus_common/msgbus/
mstr.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Type-safe string wrappers for message bus patterns, topics, and endpoints.
17
18use std::{fmt::Display, ops::Deref};
19
20use nautilus_core::correctness::{FAILED, check_valid_string_utf8};
21use serde::{Deserialize, Serialize};
22use ustr::Ustr;
23
24/// Marker for subscription patterns. Allows wildcards (`*`, `?`).
25#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
26pub struct Pattern;
27
28/// Marker for publish topics. No wildcards allowed.
29#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
30pub struct Topic;
31
32/// Marker for direct message endpoints. No wildcards allowed.
33#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
34pub struct Endpoint;
35
36/// A message bus string type parameterized by marker type.
37///
38/// - `MStr<Pattern>` - for subscriptions, allows wildcards (`*`, `?`)
39/// - `MStr<Topic>` - for publishing, no wildcards
40/// - `MStr<Endpoint>` - for direct messages, no wildcards
41#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(transparent)]
43pub struct MStr<T> {
44    value: Ustr,
45    #[serde(skip)]
46    _marker: std::marker::PhantomData<T>,
47}
48
49impl<T> MStr<T> {
50    #[inline(always)]
51    fn checked(value: Ustr, key: &str) -> anyhow::Result<Self> {
52        check_valid_string_utf8(value.as_str(), stringify!(value))?;
53
54        if value.as_bytes().iter().any(|&b| b == b'*' || b == b'?') {
55            anyhow::bail!("{key} `value` contained invalid characters, was {value}");
56        }
57
58        Ok(Self {
59            value,
60            _marker: std::marker::PhantomData,
61        })
62    }
63}
64
65impl<T> Display for MStr<T> {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        write!(f, "{}", self.value)
68    }
69}
70
71impl<T> Deref for MStr<T> {
72    type Target = Ustr;
73
74    fn deref(&self) -> &Self::Target {
75        &self.value
76    }
77}
78
79impl<T> AsRef<str> for MStr<T> {
80    fn as_ref(&self) -> &str {
81        self.value.as_str()
82    }
83}
84
85impl MStr<Pattern> {
86    /// Create a new pattern from a string.
87    pub fn pattern<T: AsRef<str>>(value: T) -> Self {
88        let value = Ustr::from(value.as_ref());
89
90        Self {
91            value,
92            _marker: std::marker::PhantomData,
93        }
94    }
95
96    /// Create a new pattern from a string, validating it can match a topic.
97    ///
98    /// Wildcards are valid in a pattern, so only empty and whitespace-only values are rejected.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error if the pattern is empty or all whitespace.
103    pub fn pattern_checked<T: AsRef<str>>(value: T) -> anyhow::Result<Self> {
104        check_valid_string_utf8(value.as_ref(), stringify!(value))?;
105
106        Ok(Self::pattern(value))
107    }
108}
109
110impl From<&str> for MStr<Pattern> {
111    fn from(value: &str) -> Self {
112        Self::pattern(value)
113    }
114}
115
116impl From<String> for MStr<Pattern> {
117    fn from(value: String) -> Self {
118        value.as_str().into()
119    }
120}
121
122impl From<&String> for MStr<Pattern> {
123    fn from(value: &String) -> Self {
124        value.as_str().into()
125    }
126}
127
128impl From<MStr<Topic>> for MStr<Pattern> {
129    fn from(value: MStr<Topic>) -> Self {
130        Self {
131            value: value.value,
132            _marker: std::marker::PhantomData,
133        }
134    }
135}
136
137impl MStr<Topic> {
138    /// Create a new topic from a fully qualified string.
139    ///
140    /// # Errors
141    ///
142    /// Returns an error if the topic has white space or invalid characters.
143    pub fn topic<T: AsRef<str>>(value: T) -> anyhow::Result<Self> {
144        Self::checked(Ustr::from(value.as_ref()), stringify!(Topic))
145    }
146
147    /// Create a topic from an already-interned Ustr.
148    ///
149    /// # Errors
150    ///
151    /// Returns an error if the topic is empty, all whitespace, or contains wildcard characters.
152    pub fn topic_from_ustr(value: Ustr) -> anyhow::Result<Self> {
153        Self::checked(value, stringify!(Topic))
154    }
155}
156
157impl From<&str> for MStr<Topic> {
158    fn from(value: &str) -> Self {
159        Self::topic(value).expect(FAILED)
160    }
161}
162
163impl From<String> for MStr<Topic> {
164    fn from(value: String) -> Self {
165        value.as_str().into()
166    }
167}
168
169impl From<&String> for MStr<Topic> {
170    fn from(value: &String) -> Self {
171        value.as_str().into()
172    }
173}
174
175impl From<Ustr> for MStr<Topic> {
176    fn from(value: Ustr) -> Self {
177        Self::topic_from_ustr(value).expect(FAILED)
178    }
179}
180
181impl From<&Ustr> for MStr<Topic> {
182    fn from(value: &Ustr) -> Self {
183        (*value).into()
184    }
185}
186
187impl MStr<Endpoint> {
188    /// Create a new endpoint from a fully qualified string.
189    ///
190    /// # Errors
191    ///
192    /// Returns an error if the endpoint has white space or invalid characters.
193    pub fn endpoint<T: AsRef<str>>(value: T) -> anyhow::Result<Self> {
194        Self::checked(Ustr::from(value.as_ref()), stringify!(Endpoint))
195    }
196
197    /// Create an endpoint from an already-interned Ustr.
198    ///
199    /// # Errors
200    ///
201    /// Returns an error if the endpoint is empty, all whitespace, or contains wildcard characters.
202    pub fn endpoint_from_ustr(value: Ustr) -> anyhow::Result<Self> {
203        Self::checked(value, stringify!(Endpoint))
204    }
205}
206
207impl From<&str> for MStr<Endpoint> {
208    fn from(value: &str) -> Self {
209        Self::endpoint(value).expect(FAILED)
210    }
211}
212
213impl From<String> for MStr<Endpoint> {
214    fn from(value: String) -> Self {
215        value.as_str().into()
216    }
217}
218
219impl From<&String> for MStr<Endpoint> {
220    fn from(value: &String) -> Self {
221        value.as_str().into()
222    }
223}
224
225impl From<Ustr> for MStr<Endpoint> {
226    fn from(value: Ustr) -> Self {
227        Self::endpoint_from_ustr(value).expect(FAILED)
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use proptest::prelude::*;
234    use rstest::rstest;
235
236    use super::*;
237
238    #[rstest]
239    #[case("data.quotes.BINANCE.BTCUSDT")]
240    #[case("events.order.filled")]
241    #[case("a")]
242    #[case("a.b.c.d.e.f")]
243    fn test_topic_valid(#[case] input: &str) {
244        let topic = MStr::<Topic>::topic(input).unwrap();
245        assert_eq!(topic.as_ref(), input);
246    }
247
248    #[rstest]
249    #[case("data.*.BINANCE")]
250    #[case("events.order.*")]
251    #[case("*")]
252    #[case("data.quotes.?")]
253    #[case("a?b")]
254    fn test_topic_rejects_wildcards(#[case] input: &str) {
255        assert!(MStr::<Topic>::topic(input).is_err());
256    }
257
258    #[rstest]
259    #[case("", "invalid string for 'value', was empty")]
260    #[case("   ", "invalid string for 'value', was all whitespace")]
261    #[case("data.*", "Topic `value` contained invalid characters, was data.*")]
262    fn test_topic_constructors_return_exact_error(#[case] input: &str, #[case] expected: &str) {
263        let string_error = MStr::<Topic>::topic(input).unwrap_err();
264        let ustr_error = MStr::<Topic>::topic_from_ustr(Ustr::from(input)).unwrap_err();
265
266        assert_eq!(string_error.to_string(), expected);
267        assert_eq!(ustr_error.to_string(), expected);
268    }
269
270    #[rstest]
271    #[case("DataEngine.execute")]
272    #[case("RiskEngine.process")]
273    fn test_endpoint_valid(#[case] input: &str) {
274        let endpoint = MStr::<Endpoint>::endpoint(input).unwrap();
275        assert_eq!(endpoint.as_ref(), input);
276    }
277
278    #[rstest]
279    #[case("DataEngine.*")]
280    #[case("*.execute")]
281    #[case("Risk?Engine")]
282    fn test_endpoint_rejects_wildcards(#[case] input: &str) {
283        assert!(MStr::<Endpoint>::endpoint(input).is_err());
284    }
285
286    #[rstest]
287    #[case("", "invalid string for 'value', was empty")]
288    #[case("   ", "invalid string for 'value', was all whitespace")]
289    #[case(
290        "Risk?Engine",
291        "Endpoint `value` contained invalid characters, was Risk?Engine"
292    )]
293    fn test_endpoint_constructors_return_exact_error(#[case] input: &str, #[case] expected: &str) {
294        let string_error = MStr::<Endpoint>::endpoint(input).unwrap_err();
295        let ustr_error = MStr::<Endpoint>::endpoint_from_ustr(Ustr::from(input)).unwrap_err();
296
297        assert_eq!(string_error.to_string(), expected);
298        assert_eq!(ustr_error.to_string(), expected);
299    }
300
301    #[rstest]
302    #[case("data.*")]
303    #[case("*.quotes.*")]
304    #[case("data.?.BINANCE")]
305    #[case("*")]
306    #[case("exact.match.no.wildcards")]
307    fn test_pattern_accepts_all(#[case] input: &str) {
308        let pattern = MStr::<Pattern>::pattern(input);
309        assert_eq!(pattern.as_ref(), input);
310    }
311
312    #[rstest]
313    #[case("data.*")]
314    #[case("*.quotes.*")]
315    #[case("data.?.BINANCE")]
316    #[case("*")]
317    #[case("exact.match.no.wildcards")]
318    fn test_pattern_checked_accepts_matchable_patterns(#[case] input: &str) {
319        let pattern = MStr::<Pattern>::pattern_checked(input).unwrap();
320        assert_eq!(pattern.as_ref(), input);
321    }
322
323    #[rstest]
324    #[case("")]
325    #[case("   ")]
326    #[case("\t\n")]
327    fn test_pattern_checked_rejects_empty_whitespace(#[case] input: &str) {
328        assert!(MStr::<Pattern>::pattern_checked(input).is_err());
329    }
330
331    #[rstest]
332    fn test_topic_to_pattern_conversion() {
333        let topic: MStr<Topic> = "data.quotes.BINANCE.BTCUSDT".into();
334        let pattern: MStr<Pattern> = topic.into();
335        assert_eq!(pattern.as_ref(), "data.quotes.BINANCE.BTCUSDT");
336    }
337
338    #[rstest]
339    fn test_topic_from_ustr_valid() {
340        let ustr = Ustr::from("data.quotes.BINANCE");
341        let topic = MStr::<Topic>::topic_from_ustr(ustr).unwrap();
342        assert_eq!(topic.as_ref(), "data.quotes.BINANCE");
343    }
344
345    #[rstest]
346    #[case("")]
347    #[case("   ")]
348    #[case("\t\n")]
349    fn test_topic_from_ustr_rejects_empty_whitespace(#[case] input: &str) {
350        let ustr = Ustr::from(input);
351        assert!(MStr::<Topic>::topic_from_ustr(ustr).is_err());
352    }
353
354    #[rstest]
355    #[case("data.*")]
356    #[case("a?b")]
357    fn test_topic_from_ustr_rejects_wildcards(#[case] input: &str) {
358        let ustr = Ustr::from(input);
359        assert!(MStr::<Topic>::topic_from_ustr(ustr).is_err());
360    }
361
362    #[rstest]
363    fn test_endpoint_from_ustr_valid() {
364        let ustr = Ustr::from("DataEngine.execute");
365        let endpoint = MStr::<Endpoint>::endpoint_from_ustr(ustr).unwrap();
366        assert_eq!(endpoint.as_ref(), "DataEngine.execute");
367    }
368
369    #[rstest]
370    #[case("")]
371    #[case("   ")]
372    fn test_endpoint_from_ustr_rejects_empty_whitespace(#[case] input: &str) {
373        let ustr = Ustr::from(input);
374        assert!(MStr::<Endpoint>::endpoint_from_ustr(ustr).is_err());
375    }
376
377    #[rstest]
378    #[case("Engine.*")]
379    #[case("a?b")]
380    fn test_endpoint_from_ustr_rejects_wildcards(#[case] input: &str) {
381        let ustr = Ustr::from(input);
382        assert!(MStr::<Endpoint>::endpoint_from_ustr(ustr).is_err());
383    }
384
385    #[rstest]
386    fn test_from_impls_equivalent() {
387        let s = "test.topic";
388        let from_str: MStr<Topic> = s.into();
389        let from_string: MStr<Topic> = s.to_string().into();
390        let from_string_ref: MStr<Topic> = (&s.to_string()).into();
391        let from_ustr: MStr<Topic> = Ustr::from(s).into();
392
393        assert_eq!(from_str, from_string);
394        assert_eq!(from_string, from_string_ref);
395        assert_eq!(from_string_ref, from_ustr);
396    }
397
398    #[rstest]
399    fn test_deref_to_ustr() {
400        let topic: MStr<Topic> = "test.topic".into();
401        let ustr: &Ustr = &topic;
402        assert_eq!(ustr.as_str(), "test.topic");
403    }
404
405    fn valid_segment() -> impl Strategy<Value = String> {
406        "[a-zA-Z][a-zA-Z0-9_]{0,15}".prop_filter("non-empty", |s| !s.is_empty())
407    }
408
409    fn valid_topic_string() -> impl Strategy<Value = String> {
410        prop::collection::vec(valid_segment(), 1..=5).prop_map(|segs| segs.join("."))
411    }
412
413    fn string_with_wildcards() -> impl Strategy<Value = String> {
414        prop::collection::vec(
415            prop_oneof![
416                valid_segment(),
417                Just("*".to_string()),
418                Just("?".to_string()),
419            ],
420            1..=5,
421        )
422        .prop_map(|segs| segs.join("."))
423        .prop_filter("must contain wildcard", |s| {
424            s.contains('*') || s.contains('?')
425        })
426    }
427
428    proptest! {
429        #[rstest]
430        fn prop_topic_roundtrip(s in valid_topic_string()) {
431            let topic = MStr::<Topic>::topic(&s).unwrap();
432            prop_assert_eq!(topic.as_ref(), s.as_str());
433        }
434
435        #[rstest]
436        fn prop_endpoint_roundtrip(s in valid_topic_string()) {
437            let endpoint = MStr::<Endpoint>::endpoint(&s).unwrap();
438            prop_assert_eq!(endpoint.as_ref(), s.as_str());
439        }
440
441        #[rstest]
442        fn prop_pattern_accepts_wildcards(s in string_with_wildcards()) {
443            let pattern = MStr::<Pattern>::pattern(&s);
444            prop_assert_eq!(pattern.as_ref(), s.as_str());
445        }
446
447        #[rstest]
448        fn prop_topic_rejects_wildcards(s in string_with_wildcards()) {
449            prop_assert!(MStr::<Topic>::topic(&s).is_err());
450        }
451
452        #[rstest]
453        fn prop_endpoint_rejects_wildcards(s in string_with_wildcards()) {
454            prop_assert!(MStr::<Endpoint>::endpoint(&s).is_err());
455        }
456
457        #[rstest]
458        fn prop_topic_to_pattern_preserves_value(s in valid_topic_string()) {
459            let topic: MStr<Topic> = MStr::topic(&s).unwrap();
460            let pattern: MStr<Pattern> = topic.into();
461            prop_assert_eq!(pattern.as_ref(), s.as_str());
462        }
463
464        #[rstest]
465        fn prop_from_impls_consistent(s in valid_topic_string()) {
466            let from_str: MStr<Topic> = s.as_str().into();
467            let from_string: MStr<Topic> = s.clone().into();
468            let from_ustr: MStr<Topic> = Ustr::from(&s).into();
469
470            prop_assert_eq!(from_str, from_string);
471            prop_assert_eq!(from_string, from_ustr);
472        }
473    }
474}