Skip to main content

emergent_client/types/
message_type.rs

1//! Message type newtype for validated message type identifiers.
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use std::fmt;
5use std::str::FromStr;
6
7/// A validated message type identifier.
8///
9/// Message types use dot-separated namespacing convention.
10/// Examples: "timer.tick", "user.created", "system.shutdown"
11///
12/// # Validation Rules
13///
14/// - Cannot be empty
15/// - Contain only lowercase letters, digits, dots, hyphens, underscores
16/// - Cannot start or end with a dot
17/// - Cannot have consecutive dots
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub struct MessageType(String);
20
21/// Error returned when creating an invalid message type.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum InvalidMessageType {
24    /// Message type cannot be empty.
25    Empty,
26    /// Message type contains invalid characters.
27    InvalidCharacters { value: String },
28    /// Message type has invalid structure.
29    InvalidStructure { value: String, reason: &'static str },
30}
31
32impl fmt::Display for InvalidMessageType {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            Self::Empty => write!(f, "message type cannot be empty"),
36            Self::InvalidCharacters { value } => {
37                write!(f, "message type '{value}' contains invalid characters")
38            }
39            Self::InvalidStructure { value, reason } => {
40                write!(f, "message type '{value}' is invalid: {reason}")
41            }
42        }
43    }
44}
45
46impl std::error::Error for InvalidMessageType {}
47
48impl MessageType {
49    /// Creates a new message type after validation.
50    ///
51    /// # Errors
52    ///
53    /// Returns an error if the message type is invalid.
54    pub fn new(value: impl Into<String>) -> Result<Self, InvalidMessageType> {
55        let value = value.into();
56
57        if value.is_empty() {
58            return Err(InvalidMessageType::Empty);
59        }
60
61        if value.starts_with('.') || value.ends_with('.') {
62            return Err(InvalidMessageType::InvalidStructure {
63                value,
64                reason: "cannot start or end with dot",
65            });
66        }
67
68        if value.contains("..") {
69            return Err(InvalidMessageType::InvalidStructure {
70                value,
71                reason: "cannot contain consecutive dots",
72            });
73        }
74
75        if !value.chars().all(|c| {
76            c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '-' || c == '_'
77        }) {
78            return Err(InvalidMessageType::InvalidCharacters { value });
79        }
80
81        Ok(Self(value))
82    }
83
84    /// Returns the message type as a string slice.
85    #[must_use]
86    pub fn as_str(&self) -> &str {
87        &self.0
88    }
89
90    /// Returns the namespace parts.
91    ///
92    /// For "user.created", returns `["user", "created"]`.
93    #[must_use]
94    pub fn parts(&self) -> Vec<&str> {
95        self.0.split('.').collect()
96    }
97
98    /// Returns the first part of the message type (the category).
99    ///
100    /// For "user.created", returns `Some("user")`.
101    #[must_use]
102    pub fn category(&self) -> Option<&str> {
103        self.0.split('.').next()
104    }
105
106    /// Checks if this message type matches a pattern with wildcards.
107    ///
108    /// Supports `*` at the end to match any suffix.
109    /// Examples:
110    /// - "timer.tick" matches "timer.tick" (exact)
111    /// - "timer.tick" matches "timer.*" (wildcard)
112    /// - "system.started.timer" matches "system.started.*" (wildcard)
113    #[must_use]
114    pub fn matches_pattern(&self, pattern: &str) -> bool {
115        if let Some(prefix) = pattern.strip_suffix(".*") {
116            self.0.starts_with(prefix)
117                && self.0.len() > prefix.len()
118                && self.0.as_bytes().get(prefix.len()) == Some(&b'.')
119        } else {
120            self.0 == pattern
121        }
122    }
123}
124
125impl fmt::Display for MessageType {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        write!(f, "{}", self.0)
128    }
129}
130
131impl FromStr for MessageType {
132    type Err = InvalidMessageType;
133
134    fn from_str(s: &str) -> Result<Self, Self::Err> {
135        Self::new(s)
136    }
137}
138
139impl AsRef<str> for MessageType {
140    fn as_ref(&self) -> &str {
141        &self.0
142    }
143}
144
145impl Serialize for MessageType {
146    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
147    where
148        S: Serializer,
149    {
150        self.0.serialize(serializer)
151    }
152}
153
154impl<'de> Deserialize<'de> for MessageType {
155    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
156    where
157        D: Deserializer<'de>,
158    {
159        let s = String::deserialize(deserializer)?;
160        Self::new(s).map_err(serde::de::Error::custom)
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn valid_message_types() {
170        assert!(MessageType::new("timer.tick").is_ok());
171        assert!(MessageType::new("user.created").is_ok());
172        assert!(MessageType::new("system.shutdown").is_ok());
173        assert!(MessageType::new("event").is_ok());
174        assert!(MessageType::new("namespace.sub.event").is_ok());
175        assert!(MessageType::new("with-hyphen").is_ok());
176        assert!(MessageType::new("with_underscore").is_ok());
177        assert!(MessageType::new("with123numbers").is_ok());
178    }
179
180    #[test]
181    fn empty_is_invalid() {
182        let result = MessageType::new("");
183        assert!(matches!(result, Err(InvalidMessageType::Empty)));
184    }
185
186    #[test]
187    fn starting_with_dot_is_invalid() {
188        let result = MessageType::new(".invalid");
189        assert!(matches!(
190            result,
191            Err(InvalidMessageType::InvalidStructure { .. })
192        ));
193    }
194
195    #[test]
196    fn ending_with_dot_is_invalid() {
197        let result = MessageType::new("invalid.");
198        assert!(matches!(
199            result,
200            Err(InvalidMessageType::InvalidStructure { .. })
201        ));
202    }
203
204    #[test]
205    fn consecutive_dots_invalid() {
206        let result = MessageType::new("in..valid");
207        assert!(matches!(
208            result,
209            Err(InvalidMessageType::InvalidStructure { .. })
210        ));
211    }
212
213    #[test]
214    fn uppercase_is_invalid() {
215        let result = MessageType::new("Invalid");
216        assert!(matches!(
217            result,
218            Err(InvalidMessageType::InvalidCharacters { .. })
219        ));
220    }
221
222    #[test]
223    fn parts_extraction() -> Result<(), InvalidMessageType> {
224        let msg_type = MessageType::new("user.created")?;
225        assert_eq!(msg_type.parts(), vec!["user", "created"]);
226        Ok(())
227    }
228
229    #[test]
230    fn category_extraction() -> Result<(), InvalidMessageType> {
231        let msg_type = MessageType::new("user.created")?;
232        assert_eq!(msg_type.category(), Some("user"));
233        Ok(())
234    }
235
236    #[test]
237    fn matches_pattern_exact() -> Result<(), InvalidMessageType> {
238        let msg_type = MessageType::new("timer.tick")?;
239        assert!(msg_type.matches_pattern("timer.tick"));
240        assert!(!msg_type.matches_pattern("timer.tock"));
241        Ok(())
242    }
243
244    #[test]
245    fn matches_pattern_wildcard() -> Result<(), InvalidMessageType> {
246        let msg_type = MessageType::new("system.started.timer")?;
247        assert!(msg_type.matches_pattern("system.started.*"));
248        assert!(msg_type.matches_pattern("system.*"));
249        assert!(!msg_type.matches_pattern("user.*"));
250        Ok(())
251    }
252
253    #[test]
254    fn serde_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
255        let msg_type = MessageType::new("timer.tick")?;
256        let json = serde_json::to_string(&msg_type)?;
257        let restored: MessageType = serde_json::from_str(&json)?;
258        assert_eq!(msg_type, restored);
259        Ok(())
260    }
261
262    #[test]
263    fn from_str_works() -> Result<(), InvalidMessageType> {
264        let msg_type: MessageType = "timer.tick".parse()?;
265        assert_eq!(msg_type.as_str(), "timer.tick");
266        Ok(())
267    }
268}