evidentsource_core/domain/
identifiers.rs

1//! Identifier types for database entities.
2//!
3//! This module contains validated string wrapper types for various identifiers
4//! used throughout the system: database names, stream names, event IDs, etc.
5
6use once_cell::sync::Lazy;
7use regex::Regex;
8use std::fmt::Display;
9
10use super::error::IdentifierError;
11
12/// Unit separator character used as a safe delimiter in composite keys.
13pub const SAFE_DELIMITER: char = '\u{001F}';
14
15/// Key name for sequence attribute in events.
16pub const SEQUENCE_KEY: &str = "sequence";
17
18/// Key name for recorded time attribute in events.
19pub const RECORDEDTIME_KEY: &str = "recordedtime";
20
21/// Pattern string for name validation (shared by multiple types).
22pub const NAME_PATTERN_STR: &str = r"^[a-zA-Z][a-zA-Z0-9\-_.]{1,127}$";
23
24/// Compiled regex for name validation.
25#[allow(clippy::declare_interior_mutable_const)]
26pub static NAME_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(NAME_PATTERN_STR).unwrap());
27
28/// Validates that a string is non-blank, under 256 characters, and contains no safe delimiter.
29fn non_blank_len_lt_256_no_safe_delimiter(value: &str) -> bool {
30    !value.trim().is_empty() && value.len() < 256 && !value.contains(SAFE_DELIMITER)
31}
32
33// =============================================================================
34// DatabaseName
35// =============================================================================
36
37/// A validated database name.
38#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
39pub struct DatabaseName(String);
40
41impl Display for DatabaseName {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.write_str(&self.0)
44    }
45}
46
47impl DatabaseName {
48    /// Create a new DatabaseName if the value matches the required pattern.
49    pub fn new(value: impl Into<String>) -> Result<Self, IdentifierError> {
50        let s = value.into();
51        if NAME_PATTERN.is_match(&s) {
52            Ok(DatabaseName(s))
53        } else {
54            Err(IdentifierError::DatabaseName(s))
55        }
56    }
57
58    /// Get the underlying string slice.
59    pub fn as_str(&self) -> &str {
60        &self.0
61    }
62}
63
64// =============================================================================
65// StreamName
66// =============================================================================
67
68/// A validated stream name.
69#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
70pub struct StreamName(String);
71
72impl std::fmt::Debug for StreamName {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        let truncated = if self.0.len() > 8 {
75            format!("{}...", &self.0[..8])
76        } else {
77            self.0.clone()
78        };
79        f.debug_tuple("StreamName").field(&truncated).finish()
80    }
81}
82
83impl Display for StreamName {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        f.write_str(&self.0)
86    }
87}
88
89impl StreamName {
90    /// Create a new StreamName if the value matches the required pattern.
91    pub fn new(value: impl Into<String>) -> Result<Self, IdentifierError> {
92        let s = value.into();
93        if NAME_PATTERN.is_match(&s) {
94            Ok(StreamName(s))
95        } else {
96            Err(IdentifierError::StreamName(Some(s)))
97        }
98    }
99
100    /// Extract stream name from a CloudEvents source URL.
101    pub fn from_source(url: &str) -> Result<Self, IdentifierError> {
102        let source = url
103            .split('/')
104            .next_back()
105            .ok_or_else(|| IdentifierError::StreamName(Some(url.to_string())))?;
106        Self::new(source)
107    }
108
109    /// Get the underlying string slice.
110    pub fn as_str(&self) -> &str {
111        &self.0
112    }
113}
114
115// =============================================================================
116// EventId
117// =============================================================================
118
119/// A validated event ID.
120#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
121pub struct EventId(String);
122
123impl std::fmt::Debug for EventId {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        let truncated = if self.0.len() > 8 {
126            format!("{}...", &self.0[..8])
127        } else {
128            self.0.clone()
129        };
130        f.debug_tuple("EventId").field(&truncated).finish()
131    }
132}
133
134impl Display for EventId {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        f.write_str(&self.0)
137    }
138}
139
140impl EventId {
141    /// Create a new EventId if the value is valid.
142    pub fn new(value: impl Into<String>) -> Result<Self, IdentifierError> {
143        let s = value.into();
144        if non_blank_len_lt_256_no_safe_delimiter(&s) {
145            Ok(EventId(s))
146        } else {
147            Err(IdentifierError::EventId(s))
148        }
149    }
150
151    /// Get the underlying string slice.
152    pub fn as_str(&self) -> &str {
153        &self.0
154    }
155}
156
157// =============================================================================
158// EventType
159// =============================================================================
160
161/// A validated event type.
162#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
163pub struct EventType(String);
164
165impl std::fmt::Debug for EventType {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        let truncated = if self.0.len() > 16 {
168            format!("{}...", &self.0[..16])
169        } else {
170            self.0.clone()
171        };
172        f.debug_tuple("EventType").field(&truncated).finish()
173    }
174}
175
176impl Display for EventType {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        f.write_str(&self.0)
179    }
180}
181
182impl EventType {
183    /// Create a new EventType if the value is valid.
184    pub fn new(value: impl Into<String>) -> Result<Self, IdentifierError> {
185        let s = value.into();
186        if non_blank_len_lt_256_no_safe_delimiter(&s) {
187            Ok(EventType(s))
188        } else {
189            Err(IdentifierError::EventType(Some(s)))
190        }
191    }
192
193    /// Get the underlying string slice.
194    pub fn as_str(&self) -> &str {
195        &self.0
196    }
197}
198
199// =============================================================================
200// EventSubject
201// =============================================================================
202
203/// A validated event subject.
204#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
205pub struct EventSubject(String);
206
207impl std::fmt::Debug for EventSubject {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        let truncated = if self.0.len() > 8 {
210            format!("{}...", &self.0[..8])
211        } else {
212            self.0.clone()
213        };
214        f.debug_tuple("EventSubject").field(&truncated).finish()
215    }
216}
217
218impl Display for EventSubject {
219    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220        f.write_str(&self.0)
221    }
222}
223
224impl EventSubject {
225    /// Create a new EventSubject if the value is valid.
226    pub fn new(value: impl Into<String>) -> Result<Self, IdentifierError> {
227        let s = value.into();
228        if non_blank_len_lt_256_no_safe_delimiter(&s) {
229            Ok(EventSubject(s))
230        } else {
231            Err(IdentifierError::EventSubject(s))
232        }
233    }
234
235    /// Create an optional EventSubject from an optional value.
236    pub fn from_option<T: AsRef<str>>(value: Option<T>) -> Result<Option<Self>, IdentifierError> {
237        match value {
238            Some(v) => Self::new(v.as_ref()).map(Some),
239            None => Ok(None),
240        }
241    }
242
243    /// Get the underlying string slice.
244    pub fn as_str(&self) -> &str {
245        &self.0
246    }
247}
248
249// =============================================================================
250// StateViewName
251// =============================================================================
252
253/// A validated state view name.
254#[derive(Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
255pub struct StateViewName(String);
256
257impl std::fmt::Debug for StateViewName {
258    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        let truncated = if self.0.len() > 16 {
260            format!("{}...", &self.0[..16])
261        } else {
262            self.0.clone()
263        };
264        f.debug_tuple("StateViewName").field(&truncated).finish()
265    }
266}
267
268impl Display for StateViewName {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        f.write_str(&self.0)
271    }
272}
273
274impl StateViewName {
275    /// Create a new StateViewName if the value matches the required pattern.
276    pub fn new(value: impl Into<String>) -> Result<Self, IdentifierError> {
277        let s = value.into();
278        if NAME_PATTERN.is_match(&s) {
279            Ok(StateViewName(s))
280        } else {
281            Err(IdentifierError::StateViewName(s))
282        }
283    }
284
285    /// Get the underlying string slice.
286    pub fn as_str(&self) -> &str {
287        &self.0
288    }
289}
290
291// =============================================================================
292// StateChangeName
293// =============================================================================
294
295/// A validated state change name.
296#[derive(Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
297pub struct StateChangeName(String);
298
299impl std::fmt::Debug for StateChangeName {
300    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301        let truncated = if self.0.len() > 16 {
302            format!("{}...", &self.0[..16])
303        } else {
304            self.0.clone()
305        };
306        f.debug_tuple("StateChangeName").field(&truncated).finish()
307    }
308}
309
310impl Display for StateChangeName {
311    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312        f.write_str(&self.0)
313    }
314}
315
316impl StateChangeName {
317    /// Create a new StateChangeName if the value matches the required pattern.
318    pub fn new(value: impl Into<String>) -> Result<Self, IdentifierError> {
319        let s = value.into();
320        if NAME_PATTERN.is_match(&s) {
321            Ok(StateChangeName(s))
322        } else {
323            Err(IdentifierError::StateChangeName(s))
324        }
325    }
326
327    /// Get the underlying string slice.
328    pub fn as_str(&self) -> &str {
329        &self.0
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn test_database_name_valid() {
339        assert!(DatabaseName::new("my-database").is_ok());
340        assert!(DatabaseName::new("MyDB_123").is_ok());
341        assert!(DatabaseName::new("a1").is_ok());
342    }
343
344    #[test]
345    fn test_database_name_invalid() {
346        assert!(DatabaseName::new("").is_err());
347        assert!(DatabaseName::new("1invalid").is_err()); // starts with number
348        assert!(DatabaseName::new("a").is_err()); // too short
349    }
350
351    #[test]
352    fn test_stream_name_from_source() {
353        let name = StreamName::from_source("http://example.com/streams/my-stream").unwrap();
354        assert_eq!(name.as_str(), "my-stream");
355    }
356
357    #[test]
358    fn test_event_id_valid() {
359        assert!(EventId::new("evt-123").is_ok());
360        assert!(EventId::new("a").is_ok());
361    }
362
363    #[test]
364    fn test_event_id_invalid() {
365        assert!(EventId::new("").is_err());
366        assert!(EventId::new("   ").is_err());
367    }
368
369    #[test]
370    fn test_event_subject_optional() {
371        assert!(EventSubject::from_option(Some("subject"))
372            .unwrap()
373            .is_some());
374        assert!(EventSubject::from_option::<&str>(None).unwrap().is_none());
375    }
376}