allsource_core/domain/value_objects/
entity_id.rs

1use crate::error::Result;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4
5/// Value Object: EntityId
6///
7/// Represents a unique identifier for an entity in the event sourcing system.
8/// Entities are the subjects of events (e.g., user-123, order-456, product-789).
9///
10/// Domain Rules:
11/// - Cannot be empty
12/// - Must be between 1 and 128 characters
13/// - Flexible format (allows any printable characters except whitespace)
14/// - Case-sensitive
15/// - Immutable once created
16///
17/// This is a Value Object:
18/// - Defined by its value, not identity
19/// - Immutable
20/// - Self-validating
21/// - Compared by value equality
22#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
23pub struct EntityId(String);
24
25impl EntityId {
26    /// Create a new EntityId with validation
27    ///
28    /// # Errors
29    /// Returns error if:
30    /// - ID is empty
31    /// - ID is longer than 128 characters
32    /// - ID contains only whitespace
33    /// - ID contains control characters
34    ///
35    /// # Examples
36    /// ```
37    /// use allsource_core::domain::value_objects::EntityId;
38    ///
39    /// let entity_id = EntityId::new("user-123".to_string()).unwrap();
40    /// assert_eq!(entity_id.as_str(), "user-123");
41    ///
42    /// let entity_id = EntityId::new("order_ABC-456".to_string()).unwrap();
43    /// assert_eq!(entity_id.as_str(), "order_ABC-456");
44    /// ```
45    pub fn new(value: String) -> Result<Self> {
46        Self::validate(&value)?;
47        Ok(Self(value))
48    }
49
50    /// Create EntityId without validation (for internal use, e.g., from trusted storage)
51    ///
52    /// # Safety
53    /// This bypasses validation. Only use when loading from trusted sources
54    /// where validation has already occurred.
55    pub(crate) fn new_unchecked(value: String) -> Self {
56        Self(value)
57    }
58
59    /// Get the string value
60    pub fn as_str(&self) -> &str {
61        &self.0
62    }
63
64    /// Get the inner String (consumes self)
65    pub fn into_inner(self) -> String {
66        self.0
67    }
68
69    /// Check if this entity ID starts with a specific prefix
70    ///
71    /// # Examples
72    /// ```
73    /// use allsource_core::domain::value_objects::EntityId;
74    ///
75    /// let entity_id = EntityId::new("user-123".to_string()).unwrap();
76    /// assert!(entity_id.starts_with("user-"));
77    /// assert!(!entity_id.starts_with("order-"));
78    /// ```
79    pub fn starts_with(&self, prefix: &str) -> bool {
80        self.0.starts_with(prefix)
81    }
82
83    /// Check if this entity ID ends with a specific suffix
84    ///
85    /// # Examples
86    /// ```
87    /// use allsource_core::domain::value_objects::EntityId;
88    ///
89    /// let entity_id = EntityId::new("user-123".to_string()).unwrap();
90    /// assert!(entity_id.ends_with("-123"));
91    /// assert!(!entity_id.ends_with("-456"));
92    /// ```
93    pub fn ends_with(&self, suffix: &str) -> bool {
94        self.0.ends_with(suffix)
95    }
96
97    /// Extract a prefix before a delimiter (if present)
98    ///
99    /// # Examples
100    /// ```
101    /// use allsource_core::domain::value_objects::EntityId;
102    ///
103    /// let entity_id = EntityId::new("user-123".to_string()).unwrap();
104    /// assert_eq!(entity_id.prefix('-'), Some("user"));
105    ///
106    /// let entity_id = EntityId::new("simple".to_string()).unwrap();
107    /// assert_eq!(entity_id.prefix('-'), None);
108    /// ```
109    pub fn prefix(&self, delimiter: char) -> Option<&str> {
110        self.0.split(delimiter).next().filter(|_| self.0.contains(delimiter))
111    }
112
113    /// Validate an entity ID string
114    fn validate(value: &str) -> Result<()> {
115        // Rule: Cannot be empty
116        if value.is_empty() {
117            return Err(crate::error::AllSourceError::InvalidInput(
118                "Entity ID cannot be empty".to_string(),
119            ));
120        }
121
122        // Rule: Maximum length 128 characters
123        if value.len() > 128 {
124            return Err(crate::error::AllSourceError::InvalidInput(
125                format!("Entity ID cannot exceed 128 characters, got {}", value.len()),
126            ));
127        }
128
129        // Rule: No control characters (check before whitespace checks)
130        if value.chars().any(|c| c.is_control()) {
131            return Err(crate::error::AllSourceError::InvalidInput(
132                "Entity ID cannot contain control characters".to_string(),
133            ));
134        }
135
136        // Rule: Cannot be only whitespace
137        if value.trim().is_empty() {
138            return Err(crate::error::AllSourceError::InvalidInput(
139                "Entity ID cannot be only whitespace".to_string(),
140            ));
141        }
142
143        // Rule: No leading/trailing whitespace
144        if value != value.trim() {
145            return Err(crate::error::AllSourceError::InvalidInput(
146                "Entity ID cannot have leading or trailing whitespace".to_string(),
147            ));
148        }
149
150        Ok(())
151    }
152}
153
154// Implement Display for ergonomic string conversion
155impl fmt::Display for EntityId {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        write!(f, "{}", self.0)
158    }
159}
160
161// Implement From<&str> for convenience
162impl TryFrom<&str> for EntityId {
163    type Error = crate::error::AllSourceError;
164
165    fn try_from(value: &str) -> Result<Self> {
166        EntityId::new(value.to_string())
167    }
168}
169
170// Implement From<String> for convenience
171impl TryFrom<String> for EntityId {
172    type Error = crate::error::AllSourceError;
173
174    fn try_from(value: String) -> Result<Self> {
175        EntityId::new(value)
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_create_valid_entity_ids() {
185        // Simple alphanumeric
186        let entity_id = EntityId::new("user123".to_string());
187        assert!(entity_id.is_ok());
188        assert_eq!(entity_id.unwrap().as_str(), "user123");
189
190        // With hyphen
191        let entity_id = EntityId::new("user-123".to_string());
192        assert!(entity_id.is_ok());
193
194        // With underscore
195        let entity_id = EntityId::new("user_123".to_string());
196        assert!(entity_id.is_ok());
197
198        // Complex format
199        let entity_id = EntityId::new("order_ABC-456-XYZ".to_string());
200        assert!(entity_id.is_ok());
201
202        // UUID format
203        let entity_id = EntityId::new("550e8400-e29b-41d4-a716-446655440000".to_string());
204        assert!(entity_id.is_ok());
205
206        // With special characters (allowed)
207        let entity_id = EntityId::new("entity:123@domain".to_string());
208        assert!(entity_id.is_ok());
209    }
210
211    #[test]
212    fn test_reject_empty_entity_id() {
213        let result = EntityId::new("".to_string());
214        assert!(result.is_err());
215
216        if let Err(e) = result {
217            assert!(e.to_string().contains("cannot be empty"));
218        }
219    }
220
221    #[test]
222    fn test_reject_whitespace_only() {
223        let result = EntityId::new("   ".to_string());
224        assert!(result.is_err());
225
226        if let Err(e) = result {
227            assert!(e.to_string().contains("cannot be only whitespace"));
228        }
229    }
230
231    #[test]
232    fn test_reject_leading_trailing_whitespace() {
233        // Leading whitespace
234        let result = EntityId::new(" user-123".to_string());
235        assert!(result.is_err());
236
237        // Trailing whitespace
238        let result = EntityId::new("user-123 ".to_string());
239        assert!(result.is_err());
240
241        // Both
242        let result = EntityId::new(" user-123 ".to_string());
243        assert!(result.is_err());
244
245        if let Err(e) = EntityId::new(" test ".to_string()) {
246            assert!(e.to_string().contains("leading or trailing whitespace"));
247        }
248    }
249
250    #[test]
251    fn test_reject_too_long_entity_id() {
252        // Create a 129-character string (exceeds max of 128)
253        let long_id = "a".repeat(129);
254        let result = EntityId::new(long_id);
255        assert!(result.is_err());
256
257        if let Err(e) = result {
258            assert!(e.to_string().contains("cannot exceed 128 characters"));
259        }
260    }
261
262    #[test]
263    fn test_accept_max_length_entity_id() {
264        // Exactly 128 characters should be OK
265        let max_id = "a".repeat(128);
266        let result = EntityId::new(max_id);
267        assert!(result.is_ok());
268    }
269
270    #[test]
271    fn test_reject_control_characters() {
272        // Newline
273        let result = EntityId::new("user\n123".to_string());
274        assert!(result.is_err());
275
276        // Tab
277        let result = EntityId::new("user\t123".to_string());
278        assert!(result.is_err());
279
280        // Null byte
281        let result = EntityId::new("user\0123".to_string());
282        assert!(result.is_err());
283
284        if let Err(e) = EntityId::new("test\n".to_string()) {
285            assert!(e.to_string().contains("control characters"));
286        }
287    }
288
289    #[test]
290    fn test_starts_with() {
291        let entity_id = EntityId::new("user-123".to_string()).unwrap();
292        assert!(entity_id.starts_with("user-"));
293        assert!(entity_id.starts_with("user"));
294        assert!(!entity_id.starts_with("order-"));
295    }
296
297    #[test]
298    fn test_ends_with() {
299        let entity_id = EntityId::new("user-123".to_string()).unwrap();
300        assert!(entity_id.ends_with("-123"));
301        assert!(entity_id.ends_with("123"));
302        assert!(!entity_id.ends_with("-456"));
303    }
304
305    #[test]
306    fn test_prefix_extraction() {
307        let entity_id = EntityId::new("user-123".to_string()).unwrap();
308        assert_eq!(entity_id.prefix('-'), Some("user"));
309
310        let entity_id = EntityId::new("order_ABC_456".to_string()).unwrap();
311        assert_eq!(entity_id.prefix('_'), Some("order"));
312
313        // No delimiter
314        let entity_id = EntityId::new("simple".to_string()).unwrap();
315        assert_eq!(entity_id.prefix('-'), None);
316    }
317
318    #[test]
319    fn test_display_trait() {
320        let entity_id = EntityId::new("user-123".to_string()).unwrap();
321        assert_eq!(format!("{}", entity_id), "user-123");
322    }
323
324    #[test]
325    fn test_try_from_str() {
326        let entity_id: Result<EntityId> = "order-456".try_into();
327        assert!(entity_id.is_ok());
328        assert_eq!(entity_id.unwrap().as_str(), "order-456");
329
330        let invalid: Result<EntityId> = "".try_into();
331        assert!(invalid.is_err());
332    }
333
334    #[test]
335    fn test_try_from_string() {
336        let entity_id: Result<EntityId> = "product-789".to_string().try_into();
337        assert!(entity_id.is_ok());
338
339        let invalid: Result<EntityId> = String::new().try_into();
340        assert!(invalid.is_err());
341    }
342
343    #[test]
344    fn test_into_inner() {
345        let entity_id = EntityId::new("test-entity".to_string()).unwrap();
346        let inner = entity_id.into_inner();
347        assert_eq!(inner, "test-entity");
348    }
349
350    #[test]
351    fn test_equality() {
352        let id1 = EntityId::new("entity-a".to_string()).unwrap();
353        let id2 = EntityId::new("entity-a".to_string()).unwrap();
354        let id3 = EntityId::new("entity-b".to_string()).unwrap();
355
356        // Value equality
357        assert_eq!(id1, id2);
358        assert_ne!(id1, id3);
359    }
360
361    #[test]
362    fn test_cloning() {
363        let id1 = EntityId::new("entity".to_string()).unwrap();
364        let id2 = id1.clone();
365        assert_eq!(id1, id2);
366    }
367
368    #[test]
369    fn test_hash_consistency() {
370        use std::collections::HashSet;
371
372        let id1 = EntityId::new("entity-123".to_string()).unwrap();
373        let id2 = EntityId::new("entity-123".to_string()).unwrap();
374
375        let mut set = HashSet::new();
376        set.insert(id1);
377
378        // Should find the same value (value equality)
379        assert!(set.contains(&id2));
380    }
381
382    #[test]
383    fn test_serde_serialization() {
384        let entity_id = EntityId::new("user-123".to_string()).unwrap();
385
386        // Serialize
387        let json = serde_json::to_string(&entity_id).unwrap();
388        assert_eq!(json, "\"user-123\"");
389
390        // Deserialize
391        let deserialized: EntityId = serde_json::from_str(&json).unwrap();
392        assert_eq!(deserialized, entity_id);
393    }
394
395    #[test]
396    fn test_new_unchecked() {
397        // Should create without validation (for internal use)
398        let entity_id = EntityId::new_unchecked("invalid\nid".to_string());
399        assert_eq!(entity_id.as_str(), "invalid\nid");
400    }
401}