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
111            .split(delimiter)
112            .next()
113            .filter(|_| self.0.contains(delimiter))
114    }
115
116    /// Validate an entity ID string
117    fn validate(value: &str) -> Result<()> {
118        // Rule: Cannot be empty
119        if value.is_empty() {
120            return Err(crate::error::AllSourceError::InvalidInput(
121                "Entity ID cannot be empty".to_string(),
122            ));
123        }
124
125        // Rule: Maximum length 128 characters
126        if value.len() > 128 {
127            return Err(crate::error::AllSourceError::InvalidInput(format!(
128                "Entity ID cannot exceed 128 characters, got {}",
129                value.len()
130            )));
131        }
132
133        // Rule: No control characters (check before whitespace checks)
134        if value.chars().any(|c| c.is_control()) {
135            return Err(crate::error::AllSourceError::InvalidInput(
136                "Entity ID cannot contain control characters".to_string(),
137            ));
138        }
139
140        // Rule: Cannot be only whitespace
141        if value.trim().is_empty() {
142            return Err(crate::error::AllSourceError::InvalidInput(
143                "Entity ID cannot be only whitespace".to_string(),
144            ));
145        }
146
147        // Rule: No leading/trailing whitespace
148        if value != value.trim() {
149            return Err(crate::error::AllSourceError::InvalidInput(
150                "Entity ID cannot have leading or trailing whitespace".to_string(),
151            ));
152        }
153
154        Ok(())
155    }
156}
157
158// Implement Display for ergonomic string conversion
159impl fmt::Display for EntityId {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        write!(f, "{}", self.0)
162    }
163}
164
165// Implement From<&str> for convenience
166impl TryFrom<&str> for EntityId {
167    type Error = crate::error::AllSourceError;
168
169    fn try_from(value: &str) -> Result<Self> {
170        EntityId::new(value.to_string())
171    }
172}
173
174// Implement From<String> for convenience
175impl TryFrom<String> for EntityId {
176    type Error = crate::error::AllSourceError;
177
178    fn try_from(value: String) -> Result<Self> {
179        EntityId::new(value)
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_create_valid_entity_ids() {
189        // Simple alphanumeric
190        let entity_id = EntityId::new("user123".to_string());
191        assert!(entity_id.is_ok());
192        assert_eq!(entity_id.unwrap().as_str(), "user123");
193
194        // With hyphen
195        let entity_id = EntityId::new("user-123".to_string());
196        assert!(entity_id.is_ok());
197
198        // With underscore
199        let entity_id = EntityId::new("user_123".to_string());
200        assert!(entity_id.is_ok());
201
202        // Complex format
203        let entity_id = EntityId::new("order_ABC-456-XYZ".to_string());
204        assert!(entity_id.is_ok());
205
206        // UUID format
207        let entity_id = EntityId::new("550e8400-e29b-41d4-a716-446655440000".to_string());
208        assert!(entity_id.is_ok());
209
210        // With special characters (allowed)
211        let entity_id = EntityId::new("entity:123@domain".to_string());
212        assert!(entity_id.is_ok());
213    }
214
215    #[test]
216    fn test_reject_empty_entity_id() {
217        let result = EntityId::new("".to_string());
218        assert!(result.is_err());
219
220        if let Err(e) = result {
221            assert!(e.to_string().contains("cannot be empty"));
222        }
223    }
224
225    #[test]
226    fn test_reject_whitespace_only() {
227        let result = EntityId::new("   ".to_string());
228        assert!(result.is_err());
229
230        if let Err(e) = result {
231            assert!(e.to_string().contains("cannot be only whitespace"));
232        }
233    }
234
235    #[test]
236    fn test_reject_leading_trailing_whitespace() {
237        // Leading whitespace
238        let result = EntityId::new(" user-123".to_string());
239        assert!(result.is_err());
240
241        // Trailing whitespace
242        let result = EntityId::new("user-123 ".to_string());
243        assert!(result.is_err());
244
245        // Both
246        let result = EntityId::new(" user-123 ".to_string());
247        assert!(result.is_err());
248
249        if let Err(e) = EntityId::new(" test ".to_string()) {
250            assert!(e.to_string().contains("leading or trailing whitespace"));
251        }
252    }
253
254    #[test]
255    fn test_reject_too_long_entity_id() {
256        // Create a 129-character string (exceeds max of 128)
257        let long_id = "a".repeat(129);
258        let result = EntityId::new(long_id);
259        assert!(result.is_err());
260
261        if let Err(e) = result {
262            assert!(e.to_string().contains("cannot exceed 128 characters"));
263        }
264    }
265
266    #[test]
267    fn test_accept_max_length_entity_id() {
268        // Exactly 128 characters should be OK
269        let max_id = "a".repeat(128);
270        let result = EntityId::new(max_id);
271        assert!(result.is_ok());
272    }
273
274    #[test]
275    fn test_reject_control_characters() {
276        // Newline
277        let result = EntityId::new("user\n123".to_string());
278        assert!(result.is_err());
279
280        // Tab
281        let result = EntityId::new("user\t123".to_string());
282        assert!(result.is_err());
283
284        // Null byte
285        let result = EntityId::new("user\0123".to_string());
286        assert!(result.is_err());
287
288        if let Err(e) = EntityId::new("test\n".to_string()) {
289            assert!(e.to_string().contains("control characters"));
290        }
291    }
292
293    #[test]
294    fn test_starts_with() {
295        let entity_id = EntityId::new("user-123".to_string()).unwrap();
296        assert!(entity_id.starts_with("user-"));
297        assert!(entity_id.starts_with("user"));
298        assert!(!entity_id.starts_with("order-"));
299    }
300
301    #[test]
302    fn test_ends_with() {
303        let entity_id = EntityId::new("user-123".to_string()).unwrap();
304        assert!(entity_id.ends_with("-123"));
305        assert!(entity_id.ends_with("123"));
306        assert!(!entity_id.ends_with("-456"));
307    }
308
309    #[test]
310    fn test_prefix_extraction() {
311        let entity_id = EntityId::new("user-123".to_string()).unwrap();
312        assert_eq!(entity_id.prefix('-'), Some("user"));
313
314        let entity_id = EntityId::new("order_ABC_456".to_string()).unwrap();
315        assert_eq!(entity_id.prefix('_'), Some("order"));
316
317        // No delimiter
318        let entity_id = EntityId::new("simple".to_string()).unwrap();
319        assert_eq!(entity_id.prefix('-'), None);
320    }
321
322    #[test]
323    fn test_display_trait() {
324        let entity_id = EntityId::new("user-123".to_string()).unwrap();
325        assert_eq!(format!("{}", entity_id), "user-123");
326    }
327
328    #[test]
329    fn test_try_from_str() {
330        let entity_id: Result<EntityId> = "order-456".try_into();
331        assert!(entity_id.is_ok());
332        assert_eq!(entity_id.unwrap().as_str(), "order-456");
333
334        let invalid: Result<EntityId> = "".try_into();
335        assert!(invalid.is_err());
336    }
337
338    #[test]
339    fn test_try_from_string() {
340        let entity_id: Result<EntityId> = "product-789".to_string().try_into();
341        assert!(entity_id.is_ok());
342
343        let invalid: Result<EntityId> = String::new().try_into();
344        assert!(invalid.is_err());
345    }
346
347    #[test]
348    fn test_into_inner() {
349        let entity_id = EntityId::new("test-entity".to_string()).unwrap();
350        let inner = entity_id.into_inner();
351        assert_eq!(inner, "test-entity");
352    }
353
354    #[test]
355    fn test_equality() {
356        let id1 = EntityId::new("entity-a".to_string()).unwrap();
357        let id2 = EntityId::new("entity-a".to_string()).unwrap();
358        let id3 = EntityId::new("entity-b".to_string()).unwrap();
359
360        // Value equality
361        assert_eq!(id1, id2);
362        assert_ne!(id1, id3);
363    }
364
365    #[test]
366    fn test_cloning() {
367        let id1 = EntityId::new("entity".to_string()).unwrap();
368        let id2 = id1.clone();
369        assert_eq!(id1, id2);
370    }
371
372    #[test]
373    fn test_hash_consistency() {
374        use std::collections::HashSet;
375
376        let id1 = EntityId::new("entity-123".to_string()).unwrap();
377        let id2 = EntityId::new("entity-123".to_string()).unwrap();
378
379        let mut set = HashSet::new();
380        set.insert(id1);
381
382        // Should find the same value (value equality)
383        assert!(set.contains(&id2));
384    }
385
386    #[test]
387    fn test_serde_serialization() {
388        let entity_id = EntityId::new("user-123".to_string()).unwrap();
389
390        // Serialize
391        let json = serde_json::to_string(&entity_id).unwrap();
392        assert_eq!(json, "\"user-123\"");
393
394        // Deserialize
395        let deserialized: EntityId = serde_json::from_str(&json).unwrap();
396        assert_eq!(deserialized, entity_id);
397    }
398
399    #[test]
400    fn test_new_unchecked() {
401        // Should create without validation (for internal use)
402        let entity_id = EntityId::new_unchecked("invalid\nid".to_string());
403        assert_eq!(entity_id.as_str(), "invalid\nid");
404    }
405}